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.
| Caller | Credential |
|---|---|
| A browser on your origin | A session cookie — CSRF-guarded |
| A native or game client | A bearer token, rotated on use |
| A store, sending a webhook | The sender’s own signature |
| A management client | A 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/rotateThe 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.