Choosing a client credential

The four

What each one actually is is the token model; how a Worker checks it is verification strategies; the two paths with the most to get wrong are signing in on mobile and server to server.

CallerCredential
A browser on your originA session cookie — CSRF-guarded
A native or game clientA bearer token, rotated on use
A store, sending a webhookThe sender’s own signature
A management clientA scoped, signed, single-use token

requireAuth() accepts the first two and nothing else. What separates them is the client, not the route.

flowchart LR
    B["A browser<br/>on your origin"] --> S["Session cookie"]
    N["A native or<br/>game client"] --> T["Bearer token"]
    W["A store, sending<br/>a webhook"] --> G["The sender's<br/>own signature"]
    M["A management<br/>client"] --> C["Scoped, signed,<br/>single-use token"]
    S --> RA["requireAuth()"]
    T --> RA
    G --> WH["Verified against the sender's<br/>key, over the exact bytes"]
    C --> CP["control-plane<br/>default-denied"]

A browser gets a session

Same origin, so no CORS and no preflight — and a cookie just works, because there is no cross-site boundary for the browser to be careful about.

Every mutating route wears the CSRF origin guard, because a cookie is a credential the browser attaches for you.

The gate is published already bound to your Worker’s trusted origins. Read it through requireSameOrigin(), which takes no argument — handing over the origin list is how a Worker ends up with two same-origin implementations free to disagree, and the weaker one is then its real policy.

A native client gets a bearer token

Two credentials, and only one is worth stealing for long.

The access token is a short-lived signed JWT presented as Authorization: Bearer. Nothing revokes it; it expires.

The refresh credential is a session row — revocable, because revoking is a DELETE.

Rotate on use:

POST /auth/token/rotate

The old refresh credential stops working the moment the new one exists. A stolen one has a useful life bounded by how long it is until the real client next refreshes.

A replay inside 30 seconds is denied and nothing else — a retry after a flaky response is not an attacker. Past that, the whole family is revoked and an audit event is written.

Register the device while you are there

Send x-pithy-device-id at sign-in and the device is registered — which is what makes sign out my old phone answerable.

A sparse re-login never erases what you already knew: an absent header means no news, not clear it.

A store gets no credential of yours

A webhook is verified by the sender’s own proof over the exact bytes received, with the timestamp inside the signed payload — so re-dating a captured delivery invalidates its own signature.

Verified is not new. Inside the freshness window a captured delivery replays as many times as it is sent, so a handler that grants, charges or deletes needs its own uniqueness key.

A management client is never a session

Its token is single-scope, single-use, body-bound and sixty seconds long — and the verified caller lands on a separate context variable, because if it landed on auth, every requireAuth() in every capability would pass for it.

What never authenticates anything

A user id in a request body.

Every route in the kit binds to the authenticated caller. A room a stranger can open in your name, or a friend request from an id the client chose, is the whole attack surface of a pairing layer — and the same reasoning holds everywhere else.

ESC