How do you handle access-token expiry, refresh races and 401 loops?
Predict first
Five tasks need a fresh token at the same time. How many refreshes happen with an actor that stores the in-flight task, and without?
import Foundation
actor Auth {
var refreshes = 0
var inflight: Task<String, Never>?
func fresh() async -> String {
if let t = inflight { return await t.value }
let t = Task { () -> String in try? await Task.sleep(nanoseconds: 20_000_000); return "new" }
inflight = t
refreshes += 1
let v = await t.value
inflight = nil
return v
}
}
actor Naive {
var refreshes = 0
func fresh() async -> String { refreshes += 1; try? await Task.sleep(nanoseconds: 20_000_000); return "new" }
}
let a = Auth(), n = Naive()
await withTaskGroup(of: String.self) { g in
for _ in 0..<5 { g.addTask { await a.fresh() }; g.addTask { await n.fresh() } }
for await _ in g {}
}
print(await a.refreshes, await n.refreshes)
- 1 5
- 5 5
- 1 1
Mental model. When the building pass expires, ten people reach the turnstile at once. If each runs to reception, the first gets a new pass and the rest are told their old paperwork is void. One person should go, and everyone waits for them.
A typical API issues a short-lived access token and a longer-lived refresh token. The client attaches the access token to each request (Authorization: Bearer …); when it expires the server answers 401, and the client exchanges the refresh token for a new pair and retries. The bugs are all about concurrency. The refresh race: ten requests fire at once, the token expired, and all ten get 401 and each starts its own refresh. The server may rotate refresh tokens, so the second refresh uses an already-used token and fails, logging the user out. The fix is to serialise and coalesce: keep refresh state in one actor that stores the in-flight refresh Task; the first 401 creates it, later callers await the same task, and when it finishes they all retry with the new token. Five concurrent callers through such an actor cause one refresh, while a naive version makes five.
The other pitfalls. The 401 loop: if the retry also returns 401 (the refresh succeeded but the token is rejected, or the endpoint genuinely needs different permission) you must not retry forever; retry once per request, and if the refresh itself fails with 401/403 treat it as logged out: clear credentials and route to sign-in, once. Refresh proactively when the token is about to expire (check exp locally) to avoid most 401s. Never send the refresh request through the same interceptor that adds the access token or retries on 401 (that is how loops start). Store tokens in the Keychain, not UserDefaults, and keep them out of logs and URLs. Make refresh idempotent where you can, and handle cancellation: one caller cancelling must not cancel the shared refresh for the others (run it in an unstructured task owned by the actor). The same logic applies with OAuth, API keys that rotate, or signed requests.
The coalescing actor lets later callers await the first refresh, so one refresh serves all five. The naive version starts one per caller. (Actor reentrancy lets the others run while the first is suspended.)
Common traps
- Each failing request starting its own refresh. With rotating refresh tokens this logs the user out; coalesce in one actor.
- Retrying a 401 forever. Retry once, and treat a failed refresh as sign-out.
They might follow up with
- Where do tokens live? The Keychain, never
UserDefaults, logs or URLs. - How do you avoid most 401s? Refresh a little before the access token’s expiry time.
Related questions
- 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…
- 12.6.2 How would you design a networking layer shared by many apps and teams: interceptors, mocking, and URLProtocol?
- 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
← How does HTTP caching work with Cache-Control, ETag and conditional…