How Long Does a Token Live, and Who Decides?
Before we start
There is a class of bug almost everyone who has built an OAuth client has hit, and almost nobody has actually diagnosed:
The user is working away, and is suddenly asked to log in again.
The signature is always the same — intermittent, not reliably reproducible, one line of
invalid_grantin the logs, and a single re-login makes it go away. So it gets filed as "a network thing" or "the server being flaky", and then it ships again in the next release.But it is usually neither. It is a concurrency bug, hiding in a place most people assume has no concurrency at all: token refresh.
This article takes that path apart end to end — why tokens come in pairs, what rotating refresh tokens impose on you, exactly how the race happens, the cost of each of the three fixes, why the persistence order is counter-intuitive, how clock skew amplifies the problem, and the most important step of all: separating "the refresh failed" from "the credential is actually dead."
Roughly ten thousand words, with three explanatory diagrams. By the end you should be able to work out which link in the chain your own intermittent logout is breaking.
This is an expansion of Chapter 1 of What Exactly Is a Claude Code "Account"?. That article covers which credential types exist; this one covers how one of them stays alive.
Contents
- Chapter 1 Why a pair of tokens, not one
- Chapter 2 Rotating: the single-use kind of refresh token
- Chapter 3 Anatomy of the race
- Chapter 4 Three fixes and what each costs
- Chapter 5 Write ordering: a counter-intuitive detail
- Chapter 6 Clocks: expiry is not as simple as it looks
- Chapter 7 Failure triage: the only case that should disturb the user
- Chapter 8 Coordinating multiple clients
- Chapter 9 Reproducing it on purpose
- Chapter 10 Observability and diagnosis
- Chapter 11 A checklist
- Closing
Chapter 1 Why a pair of tokens, not one
1.1 The short-lived one does the work
The access token is the one actually carried on every request. Its lifetime is usually short — tens of minutes to a few hours.
Short is deliberate. Anything carried on every request has the largest exposure surface: it passes through logs, through intermediaries, through anywhere that might record it. If it leaks, a short lifetime bounds the attack window.
The price is that it expires often, so something has to renew it.
1.2 The long-lived one keeps it alive
The refresh token lives much longer — weeks to months, and some implementations never expire it at all.
The key difference from the access token is exposure: it goes over the wire only at the moment of refresh, once, to one fixed endpoint. The rest of the time it sits on local disk, taking no part in business requests.
So the division of labour is clean:
- High frequency, high exposure, short life → access token
- Low frequency, low exposure, long life → refresh token
1.3 What this design buys
Three things.
The cost of a leak is compressed. An attacker with an access token has minutes, not months.
Revocation becomes affordable. With a single long-lived token, revoking it means the server must check a revocation list on every request — expensive. With a pair, the server only needs to check at refresh time: is this refresh token still valid? If not, issue nothing further; the access token expires on its own. That converts a high-frequency check into a low-frequency one.
The user does not have to log in repeatedly. This is the only part the user can directly perceive — as long as the refresh chain holds, the session simply continues.
Note that third point: the entire user-visible value of this design is "you do not have to keep logging in." So when the refresh chain breaks and the user is asked to re-authenticate, the mechanism has failed at the one promise it makes. That is why this problem deserves serious attention — it is not a corner case, it lands squarely in the middle of the design.
1.4 An overlooked consequence: state became mutable
One more thing worth naming up front, because every chapter after this rests on it.
A fixed credential — an API key, say — has a very comfortable property: it is immutable. You write it into config, every subsequent read returns the same value, and no number of readers can interfere with one another.
A token pair is not like that. Refreshing changes it. The config file goes from "a read-only constant" to "mutable state shared between processes" — which is the classic recipe for concurrency problems:
shared + mutable + multiple writers = synchronisation required A lot of token-handling code evolved from code that read an API key, and its read/write paths still treat the value as an immutable constant. These implementations did not forget to lock; they never registered that the thing had become shared mutable state.
This also explains why the bug so often appears right after a migration from API keys to OAuth: the business logic was updated, the concurrency model was not.
Chapter 2 Rotating: the single-use kind of refresh token
2.1 The trade-off between two designs
Refresh tokens come in two flavours, and the difference is whether the old one still works after use:
| Type | Old token after refresh | Security | Concurrency-friendliness |
|---|---|---|---|
| Fixed | Still valid, reusable | Lower — a leak stays usable for a long time and is hard to notice | High, refresh freely in parallel |
| Rotating | Invalidated immediately, a new one is issued | High — the leak window shrinks to a single use | Low, concurrency means conflict |
The security advantage of rotation is real: a stolen refresh token becomes worthless the moment the legitimate owner uses theirs. And if the attacker uses theirs first, the owner's next refresh fails — the failure itself becomes a leak signal.
For that reason, mainstream implementations increasingly favour rotation.
2.2 The hard constraint rotation imposes
But rotation hands the client a hard constraint:
For any given refresh token, exactly one refresh may be in flight at a time.
Under a fixed scheme this constraint does not exist — refresh ten times in parallel and all ten succeed, giving you ten equivalent access tokens and a little wasted quota. Under rotation, the first succeeds and the rest get invalid_grant.
It sounds like an easy constraint to satisfy, right up until you notice that a client is often more than one process.
2.3 Replay detection: why the server punishes everyone
There is a harsher mechanism worth knowing about.
Some implementations perform replay detection: if an already-invalidated refresh token is presented again, the server does not merely reject that one attempt — it invalidates the entire token chain, including the new token it just issued.
The reasoning is sound: a spent token being reused has only two explanations — a buggy client, or a leaked token being used by someone else. Since the server cannot tell which, it assumes the worst, cuts the chain, and forces the real user to log in again.
From a security standpoint this is entirely correct. But it means:
On a server with replay detection, one refresh race does not merely fail one client — it can knock every client offline at once.
Which explains a particularly maddening symptom: the user switched windows, and the CLI, the editor and the desktop app all demand re-authentication simultaneously.
Chapter 3 Anatomy of the race
3.1 The timeline
Written out:
T0 Process A sees the access token is near expiry, reads refresh_1, starts a refresh
T0+ε Process B also sees near expiry, also reads refresh_1, starts a refresh
T1 Server handles A: issues access_2 / refresh_2, invalidates refresh_1
T2 Server handles B: refresh_1 is already dead → 400 invalid_grant
T3 Process B concludes "the credential is dead" and triggers re-authentication Look at T0+ε: B read refresh_1 because A had not written the new one back yet. Two processes read the same copy — that is the entire race.
3.2 Why it is so easy to hit
A reasonable objection: what are the odds two processes refresh at the exact same instant?
Far higher than you would think, for three reasons.
Their trigger condition is identical. Every process decides "should I refresh?" from the same expires_at. These are not independent random events — they are synchronised by a single point in time. Like everyone's alarm being set to the same minute, "simultaneously" is the expectation, not a coincidence.
There are more processes than you think. The CLI is open, the editor extension's language server is running in the background, the desktop app is minimised — three processes reading one config file is ordinary. Some clients are internally multi-process on top of that.
Wake-up moments cluster naturally. Close the laptop lid and open it again, and every process resumes at once, discovers an expired token at once, and refreshes at once. This is the highest-probability moment for the race, and the source of "closing and reopening the lid logs me out" reports.
3.3 Why it is so hard to reproduce
Because the window is narrow.
The race only occurs between "A started a refresh" and "A wrote the new token back" — a single network round trip, typically tens to hundreds of milliseconds. To reproduce it you need two processes to start refreshing inside that window.
Manual testing will essentially never hit it. And it is even less likely in development, where you usually run a single client against a freshly written config.
The conclusion is uncomfortable: this class of bug cannot be found by "testing a few more times." It can only be found by reasoning. Which is exactly why it survives so long in so many products — nobody can reproduce it, so nobody fixes it.
3.4 A second trigger: 401-driven refresh
Everything above concerns the "I noticed it is near expiry" path. There is a second, sneakier one: being driven by a 401.
The client sends a request with an access token it believes is valid, the server returns 401, the client realises the token expired, and starts a refresh.
The problem with this path is that it is concurrent by nature: if the client has several requests in flight (very common — a completion, a diagnostic, an index update), they will all receive 401 and all start refreshing.
Worse, this path frequently bypasses the proactive-refresh machinery. In many implementations, proactive refresh goes through a locked scheduler while 401-driven refresh lives in a separate block inside an interceptor. Two paths, written independently, and the lock is on only one of them.
Worth confirming explicitly when diagnosing: do your two trigger paths call the same refresh code? If not, your lock is probably protecting half the problem.
Chapter 4 Three fixes and what each costs
4.1 Singleflight: in-process only
Singleflight is the usual first instinct: keep an in-process "refresh in progress" marker, collapse concurrent callers into one request, and have the rest wait and reuse the result.
Simple, cheap, and it does eliminate concurrent refresh within one process.
Its boundary is equally clear: it does nothing across processes. And as Chapter 3 established, multi-process is the normal case. So singleflight is necessary but nowhere near sufficient.
4.2 Cross-process locks: the timeout dilemma
What actually solves the problem is a cross-process lock — a file lock or equivalent guaranteeing one refresh at a time. Other processes wait for the lock, and on acquiring it, re-read the config first: if a new token is already there, use it and skip the refresh entirely.
This is the right approach, with one problem you must handle: what if the lock holder crashes?
Without a timeout the lock is never released and every process deadlocks. So a timeout is mandatory — but the value itself is a dilemma:
| Timeout | Consequence |
|---|---|
| Too short | One slow network call releases the lock early and a second process barges in — equivalent to no lock |
| Too long | After a crash, every other process sits idle for that long |
A reasonable choice is well above a normal refresh duration but still within user patience — typically a few to a dozen seconds. And critically, after acquiring the lock you must re-read the config rather than refreshing immediately, because the previous holder very likely already did the work.
That step is routinely forgotten. Without it, a cross-process lock degrades into "queued refreshes" — every process still refreshes, just in turn, and under rotation everyone from the second onwards still fails.
4.3 Grace periods: the most elegant, and not your call
The third fix lives on the server: after the old refresh token is invalidated, keep a short grace window (typically tens of seconds) during which repeat requests bearing the old token return the same new token instead of an error.
This is the most elegant solution — it removes the consequence of the race at the root, and the client has to do nothing.
But from a client author's perspective it has a fatal property: the decision is not yours. If the server implements it, you benefit; if not, you are back to the first two. And whether it exists, and how long the window is, is usually undocumented.
So the correct posture is: design as if there is no grace period. If there turns out to be one, that is free money.
4.4 What actually works in practice
Putting it together, the robust strategy is a cross-process lock as the primary mechanism, with singleflight as an in-process optimisation:
Need to refresh?
├─ A refresh is already in flight in this process → wait, reuse result (singleflight)
└─ No → take the cross-process lock
├─ Acquired → re-read config
│ ├─ Already new → use it, do not refresh ← most commonly omitted
│ └─ Still old → refresh, persist, release
└─ Not acquired → wait (with timeout) → re-read config → use the new one Combined with the failure triage in Chapter 7, this covers the vast majority of cases.
4.5 What if you cannot take a lock
One practical question remains: what if the environment simply cannot support a cross-process lock?
Not hypothetical. Several common cases:
- The config directory sits on a read-only filesystem, or on a network filesystem without working file locks.
- The client runs in a restricted sandbox without the necessary syscalls.
- The clients are not even on the same machine — for instance, the config directory is being synced across devices.
That last one is particularly nasty: a file-sync tool copies the config to another machine, and a process there refreshes using the same refresh token. No amount of local locking reaches it.
In these cases all you can do is degrade, in order of reliability:
| Degradation | Effect | Limit |
|---|---|---|
| Increase random jitter | Substantially lowers collision probability | Lowers, does not eliminate |
| Tighten the early-refresh window | Shortens the "everyone thinks it is time" interval | Too tight and refresh cannot outrun expiry |
| Strengthen failure triage | Collisions self-heal; the user notices nothing | This is the essential one |
Note the third: when you cannot prevent the race, the only way out is to make the race harmless. The "re-read config to detect a collision" logic from Chapter 7 goes from an optimisation to a requirement in lock-less environments.
Which yields an engineering judgement worth keeping: prevention and recovery are two independent lines of defence and cannot substitute for each other. The lock is prevention; failure triage is recovery. An implementation with only prevention collapses where locking fails; one with only recovery burns a lot of avoidable refreshes. You want both.
Chapter 5 Write ordering: a counter-intuitive detail
5.1 Use-then-persist loses your only valid copy
Even with concurrency solved, one problem surfaces only on crashes: do you use the new token first, or write it to disk first?
Instinct says "use it, and persist once you know it works" — what if the new token is broken, after all?
Under rotation, that instinct is wrong.
The left path fails because refresh_1 was invalidated the instant the server issued the new pair, regardless of whether you stored anything. So after the crash, the refresh_1 on disk is waste paper, and the only valid refresh_2 was never written down.
The user's only way out is to log in again.
Now consider the worst case of persist-then-use: the new token is broken and you stored it — you refresh one extra time. The cost is a redundant network request, not a forced logout.
5.2 Atomic writes: temp file, then rename
The act of persisting has its own subtlety.
Overwrite the config file directly, crash halfway, and what is on disk is a half-old, half-new corrupt file. That is worse for a client than no file at all — with no file it knows to re-authenticate; with a corrupt file it may fail to parse, crash, or read nonsense values.
The standard approach:
① write to a temp file (same directory, to avoid crossing filesystems)
② fsync (make sure the bytes really landed, not just in page cache)
③ atomically rename over the target The filesystem guarantees rename atomicity: at any instant, another process sees either the complete old file or the complete new one, never an intermediate state.
Step ② is routinely skipped. Skip it and, on a power loss, the rename may have taken effect while the contents had not — leaving a file of the correct length full of zeros. Ordinary crashes (a killed process) do not trigger this; only power loss and kernel panics do. Which makes it extremely rare, and extremely hard to diagnose.
5.3 One general principle
Combining this chapter with the previous ones yields a principle:
In credential management, the cost of doing something twice is always lower than the cost of losing something.
- Refresh one extra time? A wasted request.
- Lose the refresh token? The user logs in again.
These are not in the same league. So every design hesitation should tilt toward "do it one more time": persist an extra time, refresh an extra time, leave a stray file behind — anything rather than risk loss.
Chapter 6 Clocks: expiry is not as simple as it looks
6.1 Clock skew
Servers typically report expiry in one of two forms: expires_in (seconds remaining) or expires_at (an absolute instant).
expires_at looks more convenient, and it hides a trap: it is an instant on the server's clock, and you are comparing it against a local one.
If your local clock is five minutes fast, you will believe the token has expired and refresh early — wasteful but harmless. If it is five minutes slow, you will believe the token is still valid and keep using it — and then get a 401 your code may not be prepared to interpret.
The safe approach is to prefer expires_in and convert it to a local absolute instant the moment the response arrives. That way you depend only on the local clock's passage of time (a relative quantity), never on its absolute value. Relative error is far smaller than absolute error.
6.2 The early-refresh window
Do not wait until the token has actually expired. Because:
- A round trip takes time. Refreshing exactly at expiry means the request may already be too late when it leaves.
- Queueing and retries need headroom.
So implementations refresh some interval before expiry.
But that lead time has a side effect that amplifies Chapter 3: the larger the lead time, the wider the window in which several processes all decide "it is time." Everyone waking five minutes before expiry collides far more often than everyone waking thirty seconds before.
The mitigation is random jitter on the lead time — each process picks a value from a range, spreading the wake-ups. An old trick from distributed systems, equally effective here.
Jitter is not a substitute for a lock, but it substantially reduces lock contention. The two are complementary.
6.3 Sleep and suspend
One last clock-related trap: the laptop lid.
While a process is suspended, timer behaviour varies by platform — some keep running, some pause. So on resume you cannot trust any timer-based judgement and must recompute from the current instant.
And as Chapter 3 noted, resume is when every process wakes together — the peak moment for the race. So refreshes on the resume path especially need the full locking flow, not a shortcut.
Chapter 7 Failure triage: the only case that should disturb the user
This is the most important chapter. Everything before it reduces failures; this one covers what to do once one happens — because no matter how good the machinery, failures will occur.
7.1 Three kinds of failure
A failed refresh must be split into at least three categories, because the correct response differs completely:
| Category | Symptom | Credential state | Correct response |
|---|---|---|---|
| Network / server | Unreachable, timeout, 5xx | Fine | Back off and retry |
| Refresh collision | invalid_grant, but a refresh just happened locally | Fine (it was simply replaced) | Re-read config, use the new one |
| Credential really dead | invalid_grant, and the config still holds the same token | Dead | Re-authorise |
Collapsing them has very concrete consequences:
- Treat everything as "re-authorise" → a network blip logs the user out.
- Treat everything as "retry" → on a genuinely dead credential you retry forever, and the user watches a spinner with no idea what to do.
7.2 How to identify the middle one
Separating the first from the other two is easy — check whether it is invalid_grant. The hard part is the last two: they share an error code.
The method is direct: re-read the config and see whether the token changed.
Got invalid_grant
→ re-read the config file
├─ Token differs from the one I used → someone else refreshed; collision; retry with the new one
└─ Token is the same → nobody refreshed; it is genuinely dead The reasoning: if this were a collision, another process refreshed successfully, and it must already have written the new token back — because Chapter 5 says persist before use. So "the token changed on re-read" is the evidence of a collision.
Which reveals a second value in that persist-before-use rule: it does not only prevent loss, it makes collisions detectable. With use-then-persist, the other process might not have written back yet, your re-read returns the old value, and you misclassify a collision as a dead credential.
A nice example of two apparently unrelated design decisions propping each other up.
One refinement: if the re-read shows no change but the last successful refresh was very recent (a few seconds), it is still worth waiting briefly and re-reading once more — another process may have the response in hand and not yet on disk.
7.3 Re-authorisation is the last resort
A point about posture.
Asking a user to log in again is the worst thing this machinery can do. It means interrupting their work, opening a browser, walking the full authorisation flow, and possibly repeating it on several devices.
So re-authorisation must be the last resort after exhausting every other possibility, not the default reaction to invalid_grant.
A practical self-check: grep your codebase for the places that trigger re-authorisation and count them. If there is more than one, at least one of them is probably too eager. Ideally the whole client has exactly one place that can decide "we must re-authorise", and its input is an explicit conclusion that has already ruled out the first two categories.
Chapter 8 Coordinating multiple clients
8.1 Who holds the token
The chapters above assumed multiple processes reading and writing the same config file. That is the most common shape, not the only one. Broadly two architectures, with different trade-offs.
8.2 Centralised: one daemon
A single resident process holds the token exclusively; other clients ask it for one.
The upside is obvious: only one process ever refreshes, so the race disappears at the root — no locks, no re-reads, no jitter needed.
The cost is equally obvious:
- You now have a process that must always be running, and its lifecycle is engineering work in itself (how it starts, how it stays up, who restarts it, how it upgrades).
- It is a single point of failure. When it dies, every client dies with it.
- Clients need a channel to talk to it, and that channel has its own concurrency and error handling.
The interesting part: centralisation does not remove complexity, it moves it from "token refresh" to "process management." The latter is a more mature problem with more off-the-shelf answers, so the move is usually worth it — but it is not free.
8.3 Distributed: each holds its own
Every client authorises independently and holds its own token.
This is the process-level counterpart of the per-device issuance from Chapter 8 of the previous article. The advantage is no shared state at all, therefore no race; each client's problems stay its own.
The cost is that the user authorises several times, and it can run into limits when authorisation counts are capped.
A middle ground is grouping by "client family": the CLI and the editor extension share one token (they usually come from the same installation), the desktop app holds another. That narrows the sharing without asking the user to authorise too many times.
Chapter 9 Reproducing it on purpose
Chapter 3 said manual testing essentially never hits this. But it can be constructed on purpose — and once you can construct it, verifying a fix goes from "ship and watch for two weeks" to "run the test."
9.1 The core idea: widen the window
The race window equals the time between "A starts refreshing" and "A writes the new token back" — normally one round trip, a few hundred milliseconds at most.
Reproducing it does not require precise timing control, only artificially widening that window. Widen it to a few seconds and you have ample time to trigger a second process by hand.
Three ways, in increasing order of intrusiveness.
One: put a controllable delay in front of the refresh endpoint. Point the client at a local proxy that forwards to the real endpoint but waits five seconds before returning the response. A's refresh hangs for five seconds, giving you plenty of time to poke B.
This is the cleanest — it changes no client code, so you are testing the real binary's real behaviour.
Two: add a test hook inside the client. Insert a configurable sleep between "response received" and "write back to config", active only in test builds or under an environment variable.
More intrusive, but it lets you place the widened window precisely — you can widen only the "received but not persisted" segment, which is exactly what you need to test the Chapter 5 ordering problem.
Three: construct dirty state directly. Skip the real flow; hand-edit the config to contain an already-invalidated refresh token and start the client.
Fastest, and ideal for validating the Chapter 7 triage — it does not reproduce the race itself, but it reproduces the race's consequence.
9.2 A recipe you can follow
Using approach one:
① Start a local proxy forwarding the refresh endpoint, delaying 5s before responding
② Point the client at the proxy
③ Edit the stored expiry to "about to expire"
④ Start two client processes at once (or one process plus a manual second trigger)
⑤ Observe:
A → gets a new token after 5s
B → gets invalid_grant
⑥ Check B's behaviour:
✓ re-reads config and continues with the new token → the fix works
✗ triggers re-authorisation → this is the bug Step ③ deserves a note: editing the stored expiry is far more efficient than waiting for real expiry. The real lifetime may be tens of minutes and you cannot wait that long every run. "How early is early enough" is a local judgement — editing config manipulates it directly.
9.3 Turning it into a regression test
Reproducing it once is not enough. You need it to run on every change, or a refactor six months from now will bring the bug back verbatim.
Three components:
| Component | Purpose |
|---|---|
| A fake authorisation server | Implements rotation faithfully: issue new, invalidate old, return invalid_grant when the old one comes back |
| A controllable delay | Widens the window so the race happens deterministically |
| Assertions | Check the final state, not the intermediate steps |
The fake server is the key part. Automated testing against a real server is impractical — you cannot burn real tokens repeatedly, and you cannot ask it to cooperate with your timing. Rotation semantics are simple enough that a few dozen lines gives you a good-enough fake.
Assertions should check the end state, for instance:
- Do both processes end up holding a valid token?
- Is the token finally on disk the latest one?
- Was re-authorisation triggered? (Most important — it should be zero.)
Do not assert on the path (for example "B should receive invalid_grant"), because a sufficiently good implementation might avoid B ever sending that request. Assert outcomes, not routes — otherwise your test forbids better implementations.
9.4 What else the rig gets you
Once the rig exists, several related problems become testable:
- Kill the process to test write ordering: kill A inside the delay window, restart, see whether it can continue. Validates Chapter 5 directly.
- Move the local clock to test skew: shift system time forward and back, see whether expiry logic breaks. Validates Chapter 6.
- Cut the network to test triage: have the proxy never respond, see whether the client backs off or triggers re-authorisation. Validates Chapter 7.
- Start ten processes at once to test the lock: count how many real refreshes happen. Ideally one. Ten means the lock is not working; two or three means the lock works but a re-read is missing.
That last number is unusually useful: the count of real refreshes is a single metric that directly reflects whether locking and re-reading are correct. Emitting it into logs or test assertions is far more reliable than reading the code and reasoning about it.
Chapter 10 Observability and diagnosis
10.1 What to log
Logging for the refresh path has a special requirement: it must let you reconstruct a race after the fact. Races are multi-process, so single-process logging is insufficient.
At minimum:
| Field | Why |
|---|---|
| Process identifier | Without it you cannot tell who did what |
| A fingerprint of the refresh token (never the value) | To tell whether two refreshes used the same copy |
| Trigger reason | Proactive expiry, 401-driven, resume check — the distribution is informative |
| Whether the lock was acquired, and how long the wait was | An ineffective lock shows up here immediately |
| Result and error code | The raw basis for the three-way triage |
| Whether persistence succeeded | To locate "refreshed fine but never stored" |
On fingerprints: never log the token itself. A short hash prefix is enough — all you need is the "same or different" judgement, not the value.
10.2 A diagnostic order
If you have an intermittent logout in hand, work through this order:
One: confirm the error code. Is it invalid_grant? If a 401/403 or a network error is being treated as a dead credential, your problem is in triage (Chapter 7), not concurrency.
Two: look for concurrent refreshes. Find two refreshes close in time and compare their token fingerprints. Identical means the race is confirmed.
Three: check the lock. Is there one? Does acquiring it trigger a config re-read? A missing re-read is the single most common defect.
Four: check write ordering. Persist-then-use or use-then-persist? Is the write atomic?
Five: check the distribution of wake-ups. If logouts cluster after lid-open, focus on the resume path (§6.3).
Six: check lead time and jitter. A large lead time with no jitter drives collision rates up sharply.
Chapter 11 A checklist
The whole article, compressed into something you can tick off:
Concurrency
Persistence
Clocks
Failure handling
Observability
Closing
Back to the opening question: why does it suddenly ask you to log in again?
The complete chain:
- Rotating refresh tokens require exactly one refresh in flight at a time;
- Several client processes share one token and are woken by the same expiry instant;
- So they read the same refresh token and start refreshing simultaneously;
- The first succeeds, the rest get
invalid_grant; - The client treats
invalid_grantuniformly as "credential dead" and triggers re-authorisation; - And if the server has replay detection, the whole chain is invalidated and every client drops at once.
Each link is individually reasonable. Together they make an intermittent failure that is nearly impossible to reproduce.
Fixing it requires nothing exotic — a cross-process lock, a re-read after acquiring it, persist-before-use, and failure triage. Four things. The hard part is not the implementation; it is noticing that concurrency exists here at all.
Which is why I wrote this: token refresh looks like a purely sequential procedure — it expired, go get a new one, how hard can it be. That very appearance of simplicity is what lets it persist, in product after product, as a long-lived recurring bug that nobody quite fixes.
If you have one of these in hand, the checklist in Chapter 11 is meant to be used directly.
This article discusses only the public mechanisms of OAuth 2.0 and its common extensions, together with general client engineering practice. It describes no vendor's internal implementation. Error codes and behaviours follow RFC 6749 and what mainstream implementations have in common; specific servers may differ, so consult their documentation when implementing.
