You need: auth composed, and your app’s deep-link scheme decided.
Mobile uses bearer, and it is CSRF-exempt — there is no ambient credential a hostile page could cause the app to send.
Two credentials, and only one goes on every request
The token model is what each one is. Native iOS and native Android are the platform specifics, and sessions and devices is what registering the device buys.
The session is the long-lived refresh credential. It lives in secure device storage — the Keychain on iOS, encrypted preferences on Android — and nowhere else.
The access token is a short-lived JWT, fifteen minutes, that you send as Authorization: Bearer. The Worker verifies it locally against the published JWKS, so a request costs no database round-trip.
Never put the session in a header on an ordinary request. Never store the access token anywhere durable — it is meant to expire.
The flow
1. Ask for a magic link or a one-time code.
POST /auth/sign-in/magic-link
{ "email": "…", "callbackURL": "myapp://auth/callback" }The callback is your own deep link, and the scheme must be in your trusted origins:
auth({
trustedOrigins: ["myapp://", "https://app.example.com"],
}),A one-time code is often the better mobile experience: no app-switch to a mail client and back, no deep-link handling to get right, and it works when the mail app opens links in its own browser.
2. Read the session token off the response. Mobile gets it from a response header on sign-in — not from a cookie. Store it in secure storage immediately.
3. Exchange it for an access token.
GET /auth/token
Authorization: Bearer <session token>4. Send the access token on everything else.
Refresh, and where clients get it wrong
When the access token expires, mint another from the session. When the session expires, sign in again.
Two mistakes are common and both are avoidable.
Refreshing on a timer. A fifteen-minute token refreshed every fourteen minutes burns requests while the app is backgrounded and still leaves a race at the boundary. Refresh on a 401, and on resume if the token you hold has expired.
Refreshing concurrently. Five requests get 401 at once, five refreshes start, four are wasted and the interleaving is a good way to end up with a token you have already replaced. Single-flight it: one refresh at a time, and everybody waits on the same one.
Registering the device
Optional, opt-in per request, and worth doing.
Send device metadata headers at sign-in — a client-generated stable id, the platform, a human label, and the push token if you have one — and the device is registered and the session is bound to it.
Two things follow: a route that lists somebody’s devices, and a route that revokes one. Sign me out on that lost phone becomes a single call rather than a session-invalidation scheme you design yourself.
The stable id is what makes the same physical device map to one row across re-logins. Generate it once and keep it.
Social sign-in on mobile
You do not register a deep link with the provider. They only ever redirect to your Worker’s callback — and the Worker, not the provider, is what hands control back to your app.
The app passes its own deep link as the sign-in callback, the Worker completes the exchange, and then it redirects there. That is why the scheme has to be in your trusted origins.
Apple’s native flow is different and simpler: the app gets an identity token from Apple’s own SDK and sends it straight to the Worker. No return URL is involved, which is also why it is the only social flow that works from a preview deployment.
Add Apple sign-in and Add Google sign-in have the platform-native client id details.
Buying on the phone, entitled everywhere
This is the payoff for putting identity on the server rather than on the device.
A person signs in on iOS, buys through StoreKit, and the purchase resolves to an entitlement row against their user — so the same person on the web is entitled, with nothing synced and no device involved.
Entitlements on mobile and web works it through.
What to check
- The session token comes back on the sign-in response and lands in secure storage
- The exchange returns a JWT, and a protected route accepts it
- A 401 triggers exactly one refresh, and the retried request succeeds
- A revoked device’s session stops working immediately
- Nothing durable holds the access token, and nothing but secure storage holds the session