Token strategy
Skill almasumdev/awesome-mobile-backend-agent-skills/.github/skills/auth/token-strategy
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.From its SKILL.md
npx -y skills add almasumdev/awesome-mobile-backend-agent-skills --skill token-strategyAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.2 KB, ~1.2k tokens by cl100k_base, as published. Nobody here has run it
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
sidvalues 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. ADELETE /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:
// Node/TS
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? // stored in Keychain across sessions
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
- Model chosen (access+refresh vs opaque session) and documented.
- Refresh tokens stored hashed with device + family binding.
- Denylist (Redis) for urgent access-token revocation, TTL = access lifetime.
- Client refreshes proactively based on
exp, not reactively on 401 only. - Single-flight refresh enforced on client.
- Tokens stored in Keychain / EncryptedSharedPreferences.
- Logs redact token material to last 6 chars.
- Revocation propagation (pubsub) documented for resource services.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.