Tokens, Sessions & Devices
What keeps you logged in -a short-lived access pass, a rotating refresh token, and per-device sessions.
Last updated Thu Jul 16 2026 00:00:00 GMT+0000 (Coordinated Universal Time)
TL;DR -Once you sign in, the app holds two keys: a short-lived access token (good for an hour) and a long-lived refresh token that quietly renews it. Refresh tokens are single-use and rotate on every use, so a stolen one gets caught. Staying logged in lasts ~30 days of activity, not one hour.
Who this is for -Product & business: the "In plain terms" section explains why you stay logged in for weeks but a stolen phone can be cut off. Backend, security & DevOps: the token table, rotation protocol, logout/device behavior, and the separate admin-portal gate.
After you enter your one-time code, the app receives two "keys" and stores them safely on the device. Think of it like a theme park: the access token is a wristband that lets you onto rides but expires after an hour, and the refresh token is your ticket stub that lets you get a fresh wristband whenever the old one runs out -without going back to the ticket booth.
This split is what lets you stay logged in for weeks while still limiting the damage if a token is ever stolen: the powerful part (the refresh token) is single-use and swapped out every time it's used, so a copy that's used twice is instantly detected.
In plain terms
- You rarely have to log in again. The hourly wristband renews itself silently. Your effective login lasts about 30 days of activity, not one hour.
- Stolen tokens get caught. Each refresh token works exactly once. Using it produces a brand-new one and kills the old. If a thief and the real user both try the same old token, whoever's second is logged out -a standard anti-theft trap.
- A flaky connection never logs you out. Only a genuine rejection ends your session; a dropped signal during renewal leaves you signed in.
- Each device is its own session. Logging out on your phone doesn't touch your tablet, and every sign-in is recorded per device.
- Admins are a totally separate system. The staff admin portal doesn't use these user logins at all.
Token pair
The two keys, side by side:
| Token | Lifetime | Storage (client) | Notes |
|---|---|---|---|
| Access token (JWT) | 1 hour | flutter_secure_storage | Carries sub (userId), role, identifier claims; signed with JWT_ACCESS_SECRET |
| Refresh token | Long-lived | flutter_secure_storage | Opaque; stored hashed server-side in RefreshSession |
Rotation protocol
This is the single-use-and-swap trap in detail. POST /auth/refresh is strict single-use rotation:
- Look up the presented token by hash; must be unrevoked and unexpired.
- Revoke it and issue a new refresh token in the same transaction.
- Return a fresh access token + the new refresh token.
A replayed (already-rotated) refresh token is rejected -this is the standard defense against stolen-token reuse: whichever party presents the old token second gets logged out.
Client side, ApiClient pairs with this via single-flight refresh: any
number of concurrent 401s trigger exactly one refresh call (see
Networking).
If the refresh itself is definitively rejected (401/403 -revoked,
rotated away, or signed with different secrets), the client wipes the
stored session and routes to the login screen via
ApiClient.onSessionExpired (wired at bootstrap). Transient network
failures during refresh leave the session untouched -a flaky connection
never logs anyone out.
A stale access token at app launch is not a dead session: startup keeps the stored session and lets the first request 401 → refresh → retry. The effective login lifetime is therefore the refresh window (30 days, sliding), not the 1-hour access TTL.
Logout
Logging out just retires that one session, and doing it twice is harmless. POST /auth/logout revokes the matching session row. Unknown/already
revoked tokens don't error -logout is idempotent.
Devices
Every successful sign-in records the device it came from, which is what makes per-device sessions possible. Each successful OTP verify registers a Device row (platform, identifiers,
push token slot) tied to the session. This underpins per-device sessions,
future push notifications, and device-level moderation if ever needed.
Admin portal auth (separate system)
Staff access to the admin portal is a completely different mechanism -it never touches user JWTs. The admin portal does not use user JWTs. It has its own gate:
ADMIN_ACCESS_TOKEN env var → login form → SHA-256 of the token stored in
an admin_session cookie → Next.js middleware checks it on every route.
Backend /admin/* endpoints separately require the admin bearer token via
the portal's server-side proxy. See Admin portal.
Related pages
- Authentication & Identity -the OTP flow that issues these tokens.
- Networking Layer -the single-flight refresh on the client.
- Social Login (Apple & Google) -issues the same token pair.
- Admin portal -the separate staff auth system.