Contents12

The requirement was “no account, no email,” and Web Push trips on the very first step under it. Before you can send a notification you need something to hold the recipient — but with no login there is no user_id and no email to key on.

I built and shipped a price watcher called Yasugoro — it watches consumables and pushes a notification when one gets cheaper. The headline was “no login, anonymous, no email,” which meant the notification target had to live on an anonymous identifier rather than an account.

This post is only about how to deliver under that constraint, not what to deliver (the buy-timing decision). The anonymous device token, storing the subscription, handling revocation, and the two holes I fell into on real iOS and FCM devices. Once the recipient key is settled, the Web Push part itself is standard plumbing straight out of the spec.

The recipient is an anonymous device token, not email or an account

With no login, the natural identifier is an anonymous token minted per device. On the first visit Yasugoro generates one UUID v4 and stores it as a device token in a cookie.

The cookie is httpOnly + Secure + SameSite=Lax, scoped to the app’s subpath, with a one-year lifetime. Server middleware does exactly one thing: mint the token if the cookie is missing.

export function deviceTokenMiddleware() {
  return async (c, next) => {
    let token = getCookie(c, DEVICE_COOKIE);
    if (!token) {
      token = crypto.randomUUID();            // anonymous UUID v4
      setCookie(c, DEVICE_COOKIE, token, {
        path: '/yasugoro', httpOnly: true, secure: true,
        sameSite: 'Lax', maxAge: 60 * 60 * 24 * 365,
      });
    }
    c.set('deviceToken', token);
    await next();
  };
}

Taking no email means holding no personal data. The target is “this device,” not “this person” — that framing is the spine of the whole design. crypto.randomUUID() is the same API on Cloudflare Workers and Node.

Upsert a user on the device token, then hang subscriptions off it

Once the anonymous token exists, the rest is an ordinary relation. Upsert a users row keyed by a unique device token, and attach the Web Push subscriptions (push_subscriptions) to that user.

// idempotently secure a users row by device_token, return user.id
await db.prepare(
  `INSERT INTO users (device_token, settings_json, plan, created_at, updated_at)
   VALUES (?, '{}', 'free', ?, ?)
   ON CONFLICT(device_token) DO UPDATE SET updated_at = excluded.updated_at`
).bind(deviceToken, ts, ts).run();

A single user carries multiple subscription rows. Pull endpoint / p256dh / auth out of the subscription object that the browser’s PushManager.subscribe() returns, and store them. Register the same watch from a laptop and a phone, and both devices get the push.

ColumnHoldsNote
user_idusers.id secured via device tokenON DELETE CASCADE
endpointpush service URLUNIQUE (one per device+browser)
p256dh / authsubscription public key + auth secretused to encrypt the payload
uaUser-Agenttells iOS PWA apart (deliverability KPI)
revoked_atrevocation timestamped on 404/410, never DELETE

Making endpoint UNIQUE means a device that re-subscribes doesn’t pile up new rows.

flowchart TD
  Visit[First visit] --> Cookie[Mint device_token / cookie]
  Cookie --> Allow[Allow notifications]
  Allow --> Sub[PushManager.subscribe]
  Sub --> Post[POST /api/subscribe]
  Post --> User[users UPSERT by device_token]
  User --> Save[Store in push_subscriptions]
  Save --> Send[Send from Workers with VAPID]

The confirm call, POST /api/subscribe, saves the subscription and registers the watch in one request. The server already has the device token from the cookie, so the client only sends what to watch plus the subscription object.

Send from Cloudflare Workers, with VAPID over webcrypto

Sending happens from Cloudflare Workers, hitting each device endpoint with a VAPID signature. Node’s web-push library doesn’t run on Workers, so I use one that works over webcrypto (@block65/webcrypto-web-push).

There are only three moves: pull the live subscriptions, encrypt the payload and build the headers, fetch the endpoint.

const req = await buildPushPayload(
  { data: toWebPushBody(payload) },   // JSON the SW receives
  toLibSubscription(sub),             // endpoint + p256dh + auth
  { subject, publicKey, privateKey }, // VAPID keys
);
const res = await fetch(req.endpoint, req.init);

The payload is aes128gcm-encrypted with each subscription’s keys. VAPID keys come from env secrets, and the subject is a mailto: or the app URL. That is the whole send path — the notification contents (title, body, target URL) arrive as a Payload assembled upstream. No “when or why to send” logic lives here.

Real-device holes: FCM’s VAPID header and iOS standalone

Code that passed locally failed silently on a real Chrome and a real iPhone — the usual Web Push initiation. Two spots caught me.

FCM rejects the VAPID Authorization header. The library emits the older draft-01 form, Authorization: WebPush <jwt>, but FCM (behind Chrome/Android) rejects it with invalid JWT provided (403). Rewriting it to the draft-02 form, vapid t=<jwt>, k=<publicKey>, got a 201. Apple and autopush accept draft-02 too, so I transform the header right before sending.

// draft-01 "WebPush <jwt>" -> draft-02 "vapid t=<jwt>, k=<key>"
const m = headers[authKey].match(/^WebPush\s+(.+)$/i);
if (m) headers[authKey] = `vapid t=${m[1].trim()}, k=${vapid.publicKey}`;

iOS won’t subscribe unless it’s added to the home screen. Safari on iOS/iPadOS only subscribes to Web Push when the PWA is added to the home screen and launched standalone. In a normal tab, PushManager.subscribe() fails. So the client detects an iOS device that isn’t standalone and shows the add-to-home-screen guide instead of attempting a subscribe.

if (isIOS() && !isStandalone()) {
  showHomeScreenGuide();   // don't attempt subscribe
  return;
}

Skip that branch and iPhone users get the most confusing failure of all: they tap allow and nothing happens. The standalone check reads both matchMedia('(display-mode: standalone)') and navigator.standalone.

Dead subscriptions get parked with revoked_at, not deleted

A subscription whose send returns 404 or 410 gets a revoked_at timestamp instead of a DELETE, because I want to measure deliverability. Hard-deleting would erase any way to count how many issued subscriptions are still alive.

Send results split three ways by HTTP status:

  • 404 / 410 Gone — revoked. Stamp revoked_at and drop it from targets (don’t retry).
  • 429 / 5xx — transient. Mark retryable and hand it back to the upstream dispatcher.
  • any other 2xx — success.
export function classifyPushStatus(status: number) {
  if (status === 404 || status === 410) return { revoked: true, retryable: false };
  if (status === 429 || status >= 500) return { revoked: false, retryable: true };
  return { revoked: false, retryable: false };
}

The live-subscription query is WHERE user_id = ? AND revoked_at IS NULL, with a partial index on revoked_at so only active rows are read. Send to all of a user’s devices; if even one succeeds, the push counts as delivered, and I only retry when every device failed and the failures were retryable. If only revocations remain, there is no retry — the recipient is gone, so waiting won’t help.

No login, but CSRF is still guarded via a device-token HMAC

No login doesn’t excuse unguarded POSTs. Yasugoro derives the CSRF token as an HMAC keyed by the device token, so there is no server-side session to hold.

// derive the CSRF token from device_token via HMAC-SHA256 (no server state)
const key = await crypto.subtle.importKey('raw', secretBytes,
  { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key,
  new TextEncoder().encode(`csrf:${deviceToken}`));

On a POST, on top of a same-origin check (Origin/Referer), I compare the submitted _csrf against the token derivable from the cookie’s device token, using a constant-time comparison. Even with no login session, a cookie-bound token confirms that this form came from this device.

Wrapping up

Login-less Web Push mostly dissolves the moment you make the recipient an anonymous device token. From there it’s standard plumbing: hang the subscription off a user, send from Workers with VAPID, park the dead ones. What actually bit on real devices was the other two things — FCM’s header format and the iOS standalone constraint.

What I covered here is only how to deliver, not what or when to deliver — the effective-price comparison and the buy-timing decision live in another layer of Yasugoro. The three-tier effective-price calculation is in the effective-price notes, and how I matched the same product across malls is in the JAN cross-matching post. The running app is Yasugoro — try it with no login.

FAQ

With no login and no email, what holds the recipient of a notification?

On the first visit I mint an anonymous device token (UUID v4) and store it in an httpOnly cookie. That token is the key for an upserted users row, and Web Push subscriptions (push_subscriptions) hang off that user. There is no email and no account — the only identifier is an anonymous token issued to the device. One user can carry several device subscriptions, so a watch set on a phone also fires on the desktop.

Yes. When the device token changes, the app treats it as a different device and the my-watches screen shows empty. That is the trade-off of having no login: clearing history or switching browsers creates a fresh anonymous user. The token is also mirrored into localStorage to survive some cookie loss, but that is a safety net, not a promise of permanence. For people who need durability there is a separate escape hatch (LINE linking).

Why soft-revoke dead subscriptions with revoked_at instead of DELETE?

When a send returns 404/410 Gone, I keep the row and stamp revoked_at rather than deleting it, because I want deliverability as a KPI. Hard-deleting would make it impossible to count how many issued subscriptions later expired. The active-subscription query filters on revoked_at IS NULL, backed by a partial index so live rows stay cheap to read.

Why don’t notifications arrive on iOS?

Safari on iOS/iPadOS can only subscribe to Web Push when the PWA is added to the home screen and launched standalone. In a normal browser tab the subscribe call fails, so for an iOS device that isn’t standalone I skip the subscribe API entirely and show the add-to-home-screen steps instead. Miss that branch and tapping allow silently does nothing.