Auth Tokens & Idempotency Explained
DATA › Networking
A favourite fintech live-coding exercise is a token manager: cache an access token, refresh it when it expires, and stay correct when twenty coroutines ask for one at the same time. It is a small class, but it packs in two properties interviewers care about a lot: idempotency, doing an operation many times has the same effect as doing it once, and state consistency under concurrency.
Start with the shape of the whole lifecycle, because every later chunk assumes you know where each piece sits. The client authenticates with credentials or an OAuth flow and receives two tokens: a short-lived access token that gets attached to every request, and a long-lived refresh token that is used only to mint a new pair. The pair is cached. Each outgoing request reads the cached access token. When the access token is judged expired, either by checking its stored expiry or by the server returning a 401, the client exchanges the refresh token for a fresh pair. Well-designed systems also rotate the refresh token on every exchange and revoke old tokens, which is a security property, not just plumbing. On logout, every one of these must be wiped: access token, refresh token, and expiry, from memory and from disk.
Everything that follows, the race conditions, the locking, the security rules, is really about making each of these steps behave correctly when many things happen concurrently or when a step fails partway through.
With the lifecycle in view, define the property that makes half of this lesson necessary: idempotency. An operation is idempotent when performing it N times has exactly the same effect as performing it once. This matters for a token manager because a refresh call is a network request, and network requests fail, time out, and get retried, so whether refresh, and the calls it protects, is safe to repeat is not optional.
HTTP actually assigns idempotency to specific verbs. PUT is defined as idempotent: PUT /users/7/avatar replaces the resource, so sending it once or ten times leaves the same final state. DELETE is idempotent too: deleting an already-deleted resource is still 'not there' either way. GET is idempotent by definition, it doesn't change state at all. POST is the odd one out: it is defined to 'create or process', and doing that twice usually means doing it twice, two rows inserted, two emails sent, two charges made. That is precisely why POST is the verb that needs extra engineering, an idempotency key, to be made safe to retry, while PUT and DELETE get it for free from the contract of the method itself.
Now apply that to the case that actually breaks systems: a payment POST that times out. The client sent the request, but the response never arrived, so the client genuinely does not know whether the server executed the charge or not. Retrying blind risks a double charge; not retrying risks silently dropping a payment the server never received. Neither option is acceptable on its own.
The fix is an idempotency key: a unique identifier, typically a UUID, generated once per logical operation and sent alongside the request. The server stores the eventual result keyed by that id. A retry that arrives with the same key is recognised as the same logical operation, and the server returns the stored result instead of executing the charge again.
val key = UUID.randomUUID().toString() // one per logical payment, not per HTTP attempt
api.pay(amount, header = "Idempotency-Key: $key")
// request times out, client doesn't know if it succeeded
api.pay(amount, header = "Idempotency-Key: $key") // safe retry, SAME key
The detail worth remembering for an interview is where the key is generated: once, when the logical operation begins, and reused across every retry attempt of that same operation. Generating a fresh key per attempt defeats the whole mechanism, the server would see it as a brand-new payment each time.
Idempotency handles the payment side. The other half of this exercise is keeping the token cache itself correct when many coroutines call it at once, and that starts with a bug almost everyone writes on the first attempt. Here is a naive getToken():
suspend fun getToken(): String {
if (cached.isExpired()) {
cached = api.refresh(cached)
}
return cached.accessToken
}
Suspend functions do not come with any built-in mutual exclusion. If twenty coroutines call getToken() while the cached token happens to be expired, all twenty can evaluate isExpired() and get true before any single one of them has finished calling api.refresh. That is a check-then-act race: the check and the act are not atomic together, so many callers can pass the check and each independently perform the act. The result is not just wasted network calls. If the server rotates the refresh token on every exchange, the first response to land can invalidate the refresh token the other nineteen in-flight calls are still trying to use, so most of those refreshes come back as errors.
The fix is a Mutex around the critical section, combined with a re-check of the condition after the lock is acquired:
private val mutex = Mutex()
suspend fun getToken(): String {
if (!cached.isExpired()) return cached.accessToken
mutex.withLock {
if (cached.isExpired()) {
cached = api.refresh(cached)
}
}
return cached.accessToken
}
Two things matter here, and it is easy to get half credit for only mentioning one. First, Mutex is chosen over @Synchronized because @Synchronized has no idea what a suspension point is, it can deadlock or simply fail to protect a coroutine's execution across a suspend call, while mutex.withLock suspends the coroutine without blocking a thread and behaves correctly across suspension. Second, and this is the part people skip, the expiry check has to run again inside the lock. Without that inner re-check, every caller queued behind the lock still performs its own refresh once it gets in, one at a time instead of all at once, serialized instead of parallel, but still N refreshes. The re-check is what turns queued into a no-op for everyone except the first caller through.
There is a second valid shape for single-flight refresh that doesn't use a lock at all: cache the in-flight Deferred itself.
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 finds inFlight is null, so it starts the network call and stashes the Deferred. Every caller after that finds the same Deferred already sitting there and just awaits it, no new network call, just a shared subscription to the one that's already running. Clear inFlight back to null once it completes, success or failure, so the next expiry starts a genuinely new refresh.
This is worth naming out loud in an interview, because it is the thread connecting the two halves of this lesson: whether you get there with a Mutex-and-re-check or a shared Deferred, the goal is the same as an idempotency key achieves server-side. Twenty calls to getToken() while expired should produce exactly one POST /oauth/refresh and twenty identical results, many invocations, the effect of one. Idempotency and single-flight are the same idea applied on two different sides of the wire.
Single-flight solves how many refreshes happen. A separate bug lives in how the result of a refresh is stored. Suppose the token and its expiry are two separate fields, updated one after another:
private var accessToken: String? = null
private var expiresAt: Instant? = null
// ...
accessToken = newToken // write 1
expiresAt = newExpiry // write 2
Between write 1 and write 2, any reader that runs concurrently can observe a torn pair: the new token paired with the old expiry, or vice versa. Depending on which way it tears, a caller can judge a perfectly fresh token expired, or worse, treat an old token as still valid. This is a narrower version of the same discipline as the check-then-act race: state that must be read and written as a unit needs to actually be a unit.
The fix is to stop having two fields and instead hold one immutable value that gets swapped atomically:
data class Tokens(val access: String, val expiresAt: Instant)
@Volatile private var tokens: Tokens? = null // one field, swapped whole
Now every reader either sees the old Tokens or the new one, in full, never a mix of the two. @Volatile, or a StateFlow if you want readers to observe changes, makes the swap itself visible across threads without needing a lock just to read.
Single-flight refresh introduces a new failure mode: what happens when one of the waiting callers gets cancelled, its screen closes mid-refresh, say, while sixteen other callers are still awaiting the same result?
Two things need to both be true. The cancelled caller should stop waiting, its await() throws CancellationException and its coroutine unwinds normally. But the refresh itself must keep running for everyone else still awaiting it, one caller's lifecycle should never be able to abort work that other callers depend on.
That guarantee only holds if the refresh's job lives in a scope the token manager owns, scope.async on a CoroutineScope(SupervisorJob() + Dispatchers.IO) that the manager creates itself, rather than nested inside whichever caller happened to be first to trigger it. If the refresh were launched as a child of the first caller's job, cancelling that caller would cancel the shared work too, and every other awaiter would fail alongside it for no reason. The Mutex version gets a related guarantee for free: withLock releases the lock in a finally block, so a cancelled caller can never walk away holding it.
So far every scenario assumed the refresh call itself succeeds. When it doesn't, the handling has to depend on why it failed. The interesting case is a 401 on the refresh call: the refresh token itself was rejected, which means the session is dead, it has been revoked, it expired, or reuse detection flagged it as compromised.
catch (e: HttpException) {
if (e.code == 401) {
tokenStore.clear()
sessionState.value = LoggedOut
} else {
throw e // transient failure: caller may retry
}
}
There is nothing to retry here. A rejected refresh token cannot become valid by trying again, and repeatedly presenting a token that has already been flagged as reused can trip reuse-detection defences further, locking the session down harder. The correct response is to fail cleanly: clear whatever tokens are cached, flip session state to logged-out, and let the user re-authenticate. Contrast that with any other failure on the refresh call, a network blip or a 5xx, those are transient and it is fine to let the caller decide whether to retry.
That refresh-on-401 logic needs somewhere to live in the networking stack, and OkHttp gives it a specific home. There are two separate mechanisms and it is a common interview slip to conflate them. An Interceptor runs on every outgoing request and is the right place to attach the current token, it doesn't know or care whether the request will succeed. An Authenticator is invoked specifically when a response comes back 401, and its job is to supply fresh credentials for a retry, or return null to give up.
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()
}
}
The detail worth stating explicitly: the refresh call inside authenticate still needs to be the same single-flight logic from earlier in this lesson. Authenticator does not grant any free pass on the concurrency problem, if several requests 401 around the same time, authenticate can be invoked concurrently, and without single-flight underneath it, you're back to firing N refreshes.
With the mechanics of refreshing settled, the remaining chunks are about what a rejected or stolen token means for security, starting with rotation. Every successful refresh should return a brand-new refresh token and invalidate the one that was just used:
refresh(RT1) -> { access2, RT2 } // RT1 is now dead
refresh(RT1) again -> ???
If a refresh token is ever stolen, copied out of storage, intercepted, whatever the vector, the attacker and the legitimate client are now both holding what looks like a valid credential, but only one of them will win the race to use it first. Whichever one refreshes second is presenting RT1 after it has already been invalidated by the other's use. The server recognises that as reuse of a dead token rather than as an ordinary invalid-token error, and the correct response is to treat the entire token family as compromised: revoke every descendant, including the just-issued RT2, and force full re-authentication. This bounds how long a leaked refresh token stays useful to an attacker, it dies the moment either side uses it.
Rotation limits the damage of a stolen refresh token; storage is about not letting it get stolen in the first place. Refresh tokens are long-lived, bearer credentials, whoever holds one can mint new access tokens, so where they sit on disk matters as much as anything covered so far.
The app sandbox alone is not a security boundary you can rely on: it doesn't hold on a rooted device, and it doesn't hold against backups that copy app data off the device wholesale. Plain SharedPreferences is stored in cleartext inside that sandbox, so it inherits both weaknesses. BuildConfig fields and anything appended to a URL are worse, they end up baked into the APK itself or logged by proxies and analytics along the way. The acceptable answer is Keystore-backed encrypted storage, encrypted DataStore or preferences backed by a key that lives in the Android Keystore, so the ciphertext on disk is useless without a key the OS itself is guarding. Keep the decrypted value only transiently in memory for the hot path, and never let it flow into a log line or crash report.
Two last details tie the security thread together. First, idempotency keys turn out to do more than fix retries, they're also a defence against replay: if an attacker captures a payment call off the wire and resends it, or a double-tap fires the same call twice, the server sees the same key both times and returns the stored result instead of executing the effect again. That only holds if keys are unguessable and scoped to the authenticated user, otherwise a guessed or shared key could return someone else's result or let one user's retry collide with another's.
Second, everything in this lesson assumes the bearer token itself never ends up somewhere it shouldn't, and logging is the most common leak. A request logging interceptor that prints headers in a production build puts the Authorization header, a live credential, into Logcat, crash reports, and support tickets someone pastes into Slack.
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE
redactHeader("Authorization")
}
Gate verbose logging to debug builds, and redact the Authorization header even there, Base64 is an encoding, not protection, so encoding the token before logging it doesn't help at all.