The Token Refresh That Must Survive Its Caller
One design rule prevents a whole class of lockouts: never refresh an OAuth token inside the caller’s database transaction. I learned this building the credential store for an integration platform at Sprinto. The rest is generic.
The trap
An integration run opens a database transaction. Mid-run it needs an API call, finds the token expired, exchanges the refresh token for a new pair, and saves it. Then a later step fails. The transaction rolls back, and the token save rolls back with it.
Here is why that is fatal, not just wasteful. Many OAuth providers rotate refresh tokens. The old one is invalidated the moment you use it. The provider did its half of the exchange. Your database forgot yours. Your stored refresh token now points at one the provider threw away. No retry fixes it. A human has to redo the consent screen.
This is a distributed transaction hiding in plain sight. The exchange is a write to the provider’s system. The save is a write to yours. Wrap only your half in a rollback and the two systems can permanently disagree.
The fix
Commit the token in its own transaction, immediately, the moment it is minted. The caller can fail, retry, or roll back all it wants. The token is already durable.
Same rule for status. When a refresh fails in a flow that cannot be silently re-minted, mark the credential as needing re-authorization, and commit that out of band too. A failing caller must not roll back the fact that the credential is broken.
The general rule: any local write that records an irreversible external action must commit independently of the caller. Payment captures, sent emails, provisioned resources. If the outside world already changed, your record of it cannot vanish in a rollback.
The stampede
Fix durability and you meet concurrency. Ten parallel runs notice the expired token at once and all try to refresh. With rotating tokens, the first exchange invalidates what the other nine hold.
The textbook answer is a lock. But there is no natural row to lock. The protected thing is “a refresh for provider X, org Y”, a virtual resource. So instead of a lock, dedupe the work: a map from key to the in-flight promise.
const inFlight = new Map()
async function refreshToken(key) {
if (inFlight.has(key)) return inFlight.get(key)
const promise = doExchangeAndCommit(key)
.finally(() => inFlight.delete(key))
inFlight.set(key, promise)
return promise
}
The first caller starts the exchange. Everyone else awaits the same promise and shares the result. Ten wants become one exchange. For cross process races, the same idea moves into a queue with concurrency one. No distributed lock either way.
The pattern behind both fixes
Notice neither fix uses a lock. The durability fix draws a boundary. The concurrency fix collapses duplicate work into one flight. This is a habit worth building: when the shared thing is virtual (a token, a quota, a slot), look for a way to make the operation atomic or deduplicated instead of guarding it.
The same instinct shows up in a lockless rate limiter I built on Redis: every process shares one API quota, and there is no lock anywhere. One atomic script makes the decision instead.
Takeaways
- A token refresh is a distributed transaction. Your half must commit the moment the provider’s half happens.
- The caller’s transaction is the wrong boundary for a write that reflects external reality.
- Single flight is a lock for resources that have no row. It costs a map and a promise.
- Treat the token as expired a few minutes early. Clocks skew, and a token that dies mid-request is worse than an early refresh.