DEV Community

Aulvem
Aulvem

Posted on

Web Push without login: anonymous device tokens on Cloudflare Workers

"No account, no email" is a fine product line until you try to send a Web Push under it. Before you can send a notification you need something to hold the recipient — and with no login there is no user_id and no email to key on.

I hit this building a small price watcher, and solved it with an anonymous device_token. This is the minimal setup for login-less Web Push on Cloudflare Workers + Hono. Once the recipient key is settled, the rest is spec-standard plumbing.

The recipient is an anonymous device token (UUID)

With no login, the natural identifier is an anonymous token minted per device. Generate one UUID on the first visit and store it in an httpOnly cookie.

// Hono middleware: mint the token only if the cookie is missing
export function deviceTokenMiddleware() {
  return async (c, next) => {
    let token = getCookie(c, 'ysg_device');
    if (!token) {
      token = crypto.randomUUID();          // anonymous UUID v4
      setCookie(c, 'ysg_device', token, {
        path: '/', httpOnly: true, secure: true,
        sameSite: 'Lax', maxAge: 60 * 60 * 24 * 365,
      });
    }
    c.set('deviceToken', token);
    await next();
  };
}
Enter fullscreen mode Exit fullscreen mode

crypto.randomUUID() is the same API on Workers and Node. Taking no email means holding no personal data. The target is "this device," not "this person" — that framing is the whole trick.

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

Once the token exists, it's an ordinary relation. Upsert a users row keyed by a unique device_token, and attach Web Push subscriptions to that user (D1 / SQLite here).

await db.prepare(
  `INSERT INTO users (device_token, created_at, updated_at)
   VALUES (?, ?, ?)
   ON CONFLICT(device_token) DO UPDATE SET updated_at = excluded.updated_at`
).bind(deviceToken, ts, ts).run();
Enter fullscreen mode Exit fullscreen mode

A single user carries multiple subscription rows. Store endpoint / p256dh / auth from the object PushManager.subscribe() returns, and both a laptop and a phone get the push. Make endpoint UNIQUE so re-subscribing doesn't pile up rows.

CREATE TABLE push_subscriptions (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  endpoint   TEXT UNIQUE NOT NULL,
  p256dh     TEXT NOT NULL,
  auth       TEXT NOT NULL,
  revoked_at TEXT               -- stamped on 404/410; never DELETE
);
Enter fullscreen mode Exit fullscreen mode

Soft-revoking dead subscriptions with revoked_at (instead of DELETE) lets you read the live ones with WHERE revoked_at IS NULL.

Send from Workers, with VAPID over webcrypto

Node's web-push doesn't run on Workers, so use one built on webcrypto (@block65/webcrypto-web-push). Three moves: pull the live subscriptions, encrypt the payload and build headers, fetch the endpoint.

const req = await buildPushPayload(
  { data: { title, body, data: { url }, tag } },  // JSON the SW receives
  { endpoint, expirationTime: null, keys: { p256dh, auth } },
  { subject, publicKey, privateKey },             // VAPID keys (env)
);
const res = await fetch(req.endpoint, req.init);
Enter fullscreen mode Exit fullscreen mode

The payload is aes128gcm-encrypted with each subscription's keys. VAPID keys come from env secrets; the subject is a mailto: or your app URL.

Two real-device holes: FCM's header and iOS standalone

Code that passed locally failed silently on a real Chrome and a real iPhone. Two spots.

FCM rejects the VAPID Authorization header. The library emits the older draft-01 form, Authorization: WebPush <jwt>, and FCM (behind Chrome/Android) rejects it with invalid JWT provided (403). Rewrite it to the draft-02 form, vapid t=<jwt>, k=<publicKey>, and it returns 201. Apple and autopush accept draft-02 too.

const m = headers[authKey].match(/^WebPush\s+(.+)$/i);
if (m) headers[authKey] = `vapid t=${m[1].trim()}, k=${publicKey}`;
Enter fullscreen mode Exit fullscreen mode

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

if (isIOS() && !isStandalone()) {
  showHomeScreenGuide();   // don't attempt subscribe
  return;
}
// standalone = matchMedia('(display-mode: standalone)') OR navigator.standalone
Enter fullscreen mode Exit fullscreen mode

Skip that branch and iPhone users get the most confusing failure of all: they tap allow, and nothing happens.

Wrapping up

Login-less Web Push mostly dissolves the moment the recipient becomes an anonymous device token. Hang the subscription off a user, send from Workers with VAPID, and the rest is standard. What actually bit on real devices was FCM's header format and the iOS standalone constraint.

The deliverability-KPI reasoning behind keeping revoked rows, CSRF without a session (an HMAC keyed by the device token), and delivering notifications idempotently via an outbox — the full operational notes live on Aulvem → Aulvem | Web Push without login. The running app is Yasugoro — try it with no login.

Top comments (0)