Your app has a web front end and a mobile client. On the web you reach for cookies. On mobile there are no cookies worth having, so you reach for bearer tokens. Now you have two auth systems, two session models, two sets of edge cases, and a bug that only reproduces on iOS.
pithy add auth treats them as one problem with two presentations.
Two strategies, one session model
A route declares how its caller is verified, and there are two ways a person can be:
bearer— a short-lived access token in anAuthorizationheader. What a mobile client uses.session— a cookie-backed web session, CSRF-protected. What a browser uses.
Both resolve to the same session underneath. So a capability that needs a signed-in user stacks requireAuth() and stops caring which one the caller used. payments does not have a mobile code path and a web code path. It has a user.
Sign-in itself is passwordless by default — magic link and one-time code — with social providers available. Provider credentials are read from the secrets store and never from your config file, which is the difference between a credential you can rotate and one that is committed.
Underneath, Better Auth does the heavy lifting on the standard flows. Pithy’s catch-all hands it the untouched request rather than parsing first, and adds the parts a kit has to own: the device registry, refresh rotation, the audit events, and the seam other capabilities gate on.
Refresh rotation, and the race you will actually hit
Short-lived access tokens need refreshing, and a refresh token that never changes is a password with extra steps. So each refresh consumes the old token and issues a new one.
The problem is that mobile clients fire two refreshes at once more often than you would think. A view appears, two requests both see an expired token, and both refresh. If your implementation is “check it exists, then delete it, then issue a new one”, both calls pass the check and you have handed out two valid families — or worse, both delete and the user is signed out.
The consume is atomic. Exactly one call is the winner:
/** The outcome of consuming a session: whether THIS call removed the row, and the consumed family. */
export interface ConsumeResult {
/** True when this call deleted the session row — the single winner of a concurrent rotation. */
won: boolean;
}The loser knows it lost, and can behave accordingly instead of racing.
A replayed token takes the family with it
Now the case that matters.
If a refresh token is used twice, one of two things is true: your client has a bug, or somebody stole it. You cannot tell which from the request, and the safe reading is the second one.
So a reused refresh token does not just get rejected. It revokes the entire token family — every session descended from that original sign-in. This is the behavior RFC 6819 recommends for exactly this situation, and the reasoning is worth stating plainly: if an attacker has a copy of a refresh token, rejecting the replay only means the attacker and the user race for the next rotation. Killing the family ends it. The user signs in again, which is a small cost for the one case in a thousand where it was real.
sequenceDiagram
actor T as Whoever has the copy
actor U as The user
participant W as Your Worker
U->>W: Refresh with rt-1
W-->>U: rt-2, and rt-1 is consumed
T->>W: Refresh with rt-1 again
W->>W: A consumed token, used twice
Note over W: A client bug or a theft. You cannot tell<br/>from the request, so read it as theft.
W--xU: The whole family is revoked
W--xT: Including this one
The reuse also writes a critical event to your audit trail, because it is the single most interesting security signal an auth system produces.
Devices are first class
A mobile client can identify itself at sign-in with a set of headers — device id, platform, name, model, OS version, app version, push token.
That buys you three things you would otherwise build:
- A sessions list a user can look at, that says “iPhone 15, last used Tuesday” rather than showing an opaque id.
- Per-device revocation. Sign out one phone without signing out the browser, because sessions resolve by device.
- A push token already associated with the right device, rather than a second table you maintain in parallel.
None of it is required. Send the headers and you get the registry; do not and auth works exactly as before.
What you should know before composing it
The device metadata is client-supplied, which means it is a convenience and not a security boundary. A device name is whatever the client said it was. Do not gate anything on it.
Passwordless means your users need working email, and email deliverability is a real problem you now own — which is why auth sends through the email capability with its durable jobs and suppression list rather than calling a provider inline.
And an unauthenticated route that sends mail to any supplied address is the most abusable endpoint in most products, which is why magic-link and OTP routes take a Turnstile gate when you have composed one.
The seam that makes the rest work
The reason auth is worth composing rather than assembling is what it exports: requireAuth().
Every other capability that needs a signed-in user stacks it. payments gates on it, support links a conversation to it, ledger opens a balance for it. None of them re-implement session resolution, and none of them disagree with each other about what a signed-in user is — because there is one answer, and it lives in your Worker.