| name | token-strategy |
| description | Choose between access+refresh tokens and opaque server sessions for mobile; design revocation and device binding. Use when deciding the token model for a new mobile backend or hardening an existing one. |
Token Strategy for Mobile
Instructions
There are two defensible token models for mobile: access + refresh tokens (OAuth-style) and opaque server sessions (cookie-like). Pick one deliberately; mixing them creates revocation holes.
1. Access + Refresh (Recommended for Most Apps)
- Access token: short-lived (5–15 min), signed JWT, carried in
Authorization: Bearer <jwt>.
- Refresh token: long-lived (30–90 days), opaque, rotated on every use, server stores only a hash.
- Best when there are multiple resource services that verify tokens independently.
- Revocation is soft -- you rely on short access-token TTL plus a denylist for urgent cases.
2. Opaque Server Sessions
- A single random session id (ULID or 32-byte random) stored in server-side session store (Redis, DB).
- Client sends it in
Authorization: Bearer <sid> or a secure cookie (for WebView contexts).
- Best when you control a single API surface and want instant revocation.
- Every request costs a session-store lookup; cache carefully (in-process short-TTL) to stay fast.
3. Decision Matrix
| Need | Access + Refresh | Opaque Session |
|---|
| Multi-service fan-out without central lookup | best | adds latency |
| Instant global logout | denylist needed | native |
| Offline-ish clients that batch requests | fine | fine |
| Third-party API exposure (OAuth) | native | awkward |
| Simple backends, one API | overkill | best |
4. Revocation Design
For access + refresh:
- Refresh rotation detects reuse; revokes the session family on replay.
- Denylist (Redis set) of revoked
sid values with TTL = access-token max lifetime. Resource services check on each request (cheap, in-memory).
- Publish revocation events (Kafka/Redis pubsub) so edge caches can purge.
For opaque sessions:
- Session row carries
revoked_at, last_seen_at. A DELETE /sessions/{id} immediately invalidates.
- Cache session lookups in-process for ≤ 60 s; accept the eventual-consistency window.
5. Device Binding
Regardless of model, bind every credential to a device_id:
async function login(userId: string, deviceId: string) {
const family = await sessions.createFamily({ userId, deviceId });
const access = signJwt({ sub: userId, sid: family.id, device_id: deviceId, exp: now() + 900 });
const refresh = randomBytes(32).toString("base64url");
await sessions.storeRefresh({ familyId: family.id, hash: hmac(refresh) });
return { access_token: access, refresh_token: refresh, expires_in: 900 };
}
A stolen token reused from a different device (detected via attestation or client-supplied device_id that the server re-verifies) triggers family revocation.
6. Client Handling
iOS (Swift):
actor TokenStore {
private var access: String?
private var refresh: String?
func authorize(_ request: inout URLRequest) async throws {
if try await isExpiringSoon() { try await refreshTokens() }
request.setValue("Bearer \(access!)", forHTTPHeaderField: "Authorization")
}
}
Android (Kotlin, OkHttp Authenticator for 401 retry):
class AuthInterceptor(private val tokens: TokenStore) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val req = chain.request().newBuilder()
.header("Authorization", "Bearer ${tokens.access()}")
.build()
return chain.proceed(req)
}
}
class RefreshAuthenticator(private val tokens: TokenStore) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.responseCount >= 2) return null
val newAccess = runBlocking { tokens.refresh() } ?: return null
return response.request.newBuilder().header("Authorization", "Bearer $newAccess").build()
}
}
Key rules on the client:
- Refresh tokens live in Keychain (iOS) / EncryptedSharedPreferences (Android). Never plain storage.
- Only one refresh in flight; serialize concurrent refreshes (mutex /
actor).
- On
invalid_grant, drop both tokens and force re-login.
7. Clock Skew
Treat the server clock as truth. Refresh access tokens when exp - now() < 60s, not when you get a 401 -- that avoids user-visible pauses.
8. Token Leakage Mitigations
- Never log full tokens; log only the last 6 chars.
- Pin TLS certificates for the auth endpoint if your threat model includes MITM.
- On suspected compromise (jailbreak/root detected + sensitive action), force re-auth.
Checklist