Auth Tokens & Idempotency Explained
DATA › Networking
A favourite fintech live-coding exercise: build a token manager that caches an access token, refreshes it when expired, and stays correct when twenty coroutines ask for it at once. It packs two ideas into one small class.
**The token lifecycle**: authenticate with credentials or OAuth to receive an access token (short-lived) and a refresh token (long-lived), cache them securely, attach the access token to each request, detect expiry, exchange the refresh token for a new pair, handle rotation, and wipe everything on logout.
**Idempotency**: an operation is idempotent when performing it N times has the same effect as performing it once. PUT /users/7/avatar is idempotent, replacing the same resource repeatedly leaves it in the same state. A raw POST /payments is not, repeating it can charge a card twice. This lesson builds the token manager and shows exactly where idempotency has to be engineered in, not assumed.
That extra mechanism is the **idempotency key**: a unique id, typically a UUID, generated once per logical operation and sent with the request. The server stores the result keyed by it, so a retry carrying the same key returns the stored result instead of re-executing the operation.
val key = UUID.randomUUID().toString() // one per logical payment, not per HTTP attempt
api.pay(amount, header = "Idempotency-Key: $key")
// network times out, response never arrives, client doesn't know if it succeeded
api.pay(amount, header = "Idempotency-Key: $key") // safe retry, SAME key
This is why the key must be generated once and reused across retries of the same logical call, a new key per attempt defeats the whole point, it would just look like a second payment.
Now the concurrency problem. Here's a naive token accessor:
suspend fun getToken(): String {
if (cached.isExpired()) { // many callers can pass this together
cached = api.refresh(cached) // N refreshes fire in flight
}
return cached.accessToken
}
This is a classic **check-then-act** race. Suspend functions don't run on multiple threads simultaneously by magic protection, if twenty coroutines call getToken() while the cached token is expired, all twenty can observe isExpired() == true before any of them finishes refreshing, and all twenty fire their own network refresh. With refresh-token rotation in play, that's not just wasteful, the first response to arrive can invalidate the refresh tokens the other nineteen calls are about to submit.
The fix is a Mutex guarding the critical section, plus a **re-check inside the lock**:
private val mutex = Mutex()
suspend fun getToken(): String {
if (!cached.isExpired()) return cached.accessToken
mutex.withLock {
if (cached.isExpired()) { // re-check!
cached = api.refresh(cached)
}
}
return cached.accessToken
}
The re-check is the whole trick: while a caller was waiting for the lock, the previous holder likely already refreshed. Without re-checking, every queued caller would still fire its own refresh, just one at a time instead of all at once, serialized, but still N refreshes. Mutex suspends rather than blocking a thread, so it's safe across suspension points, unlike @Synchronized, which doesn't cooperate with coroutines at all.
A second valid shape caches a Deferred of the in-flight refresh instead of a lock:
private var inFlight: Deferred<Token>? = null
suspend fun refresh(): Token = coroutineScope {
(inFlight ?: scope.async { api.refresh(cached) }
.also { inFlight = it })
.await()
}
The first caller in creates the Deferred and kicks off the network call; every caller after that finds the same Deferred already sitting there and just awaits it, one network call, one result, fanned out to everyone. Clear inFlight back to null once it completes (success or failure) so the next expiry starts a fresh refresh. Note the async runs on scope, a scope the token manager owns, not the caller's own job, that detail matters for the next chunk.
Now the cancellation edge case. Say caller #3 is awaiting the shared refresh when its screen closes and its coroutine gets cancelled. Two things must both be true: caller #3 stops waiting (its await() throws CancellationException), but the refresh itself **keeps running** for the other seventeen callers still awaiting it.
That only works if the refresh's job lives in a scope the token manager owns (scope.async { ... } above), not nested inside whichever caller happened to start it, cancelling that caller's job would otherwise cancel the shared work too. The Mutex version has its own guarantee here for free: withLock releases the lock in a finally block, so even a cancelled caller can never leave it held.
Two failure and security details finish the picture. First, **refresh-token rotation**: every successful refresh returns a brand-new refresh token and invalidates the old one. If a stolen (now-old) refresh token is ever replayed, the server sees reuse of an invalidated token, treats the whole token family as compromised, and revokes it, capping how long a leaked token stays useful.
Second, when the refresh call itself comes back 401, the refresh token was rejected outright, meaning the session is dead. There's nothing to retry:
catch (e: HttpException) {
if (e.code == 401) { tokenStore.clear(); sessionState.value = LoggedOut }
else throw e // transient failure: caller may retry
}
Retrying a rejected refresh can never succeed and can trip reuse detection further, the correct move is to fail cleanly and route to re-authentication.
Last piece: where tokens live and how they attach to requests. Store refresh tokens in Keystore-backed encrypted storage (encrypted DataStore or preferences), never plaintext SharedPreferences, files, logs, crash reports, URLs, or hardcoded in the APK, all of those leak via backups, root access, or a support ticket someone pastes a log into.
For attaching and reacting to expiry in OkHttp, the two roles are distinct: an **Interceptor** attaches the current token to outgoing requests, while an **Authenticator** reacts specifically to a 401 and supplies a fresh token for the retry:
class TokenAuthenticator(private val repo: TokenRepo) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val fresh = repo.refreshBlockingSingleFlight() ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $fresh").build()
}
}
That refresh call inside authenticate still needs to be the same single-flight logic from earlier, an Authenticator doesn't grant you a free pass on the concurrency problem.