Build1 distinct publisher3 min readUpdated
A dev.to writeup argues the intermittent OAuth failure is a concurrency bug on a rotating token, and that retrying it looks exactly like the replay attack reuse detection exists to catch.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
Triage order matters more than the taxonomy. Of the four things `invalid_grant` can mean on a refresh call, three are steady states: an idle-expired refresh token, a revoked grant, and client credentials that do not match the token will fail every time you try them [5]. Only reuse-after-rotation comes and goes [6]. So the useful first question is not what the string means, it is whether the errors are bursty, whether they cluster on traffic spikes or cron minute boundaries, and whether they hit accounts that were working ten minutes ago [6].
The arithmetic explains why this survives staging. One process locally; a web dyno, three queue workers and a scheduled job in production, all reading the same row [9], which is five candidates for the same refresh [1]. The refresh path opens two minutes early, because the fast path only hands back the cached access token while `expires_at` minus a 120-second skew is still in the future [14]. That gives a 120-second band in which any of those five will independently decide the token is stale [2]. The overlap in the worked example is 40 ms wide [10], roughly three hundredths of a percent of the band [3]. A single-process dev box never rolls that dice.
Retrying is the wrong layer, and the writeup is blunt about the reason: a retry loop turns one replayed token into five, which is indistinguishable from the attack reuse detection was built to catch [11]. The damage is also not confined to the loser. Where the provider revokes the family, worker A's brand-new token dies alongside B's replayed one [10], and the recovery path runs through a human pressing Reconnect [2].
The lock itself is the cheap part. An advisory transaction lock keyed on a stable hash of the integration id needs no new infrastructure, has no lease expiry to reason about, and releases when the transaction ends, including when the connection dies [13][15]. The step that does the actual single-flighting is the re-read inside the lock [15]; serialising the workers without re-checking state would queue five refreshes rather than running them concurrently, which is the same number of redemptions arriving in a politer order. The configured 15-second `lock_timeout` is the ceiling on how long a queued worker waits before the statement gives up [15][4].
One caution about the evidence. This is a single writeup with no incident data attached, and it says many providers treat reuse as replay without naming them [2]. It also says the behaviour can differ per app registration, which makes provider documentation the weaker authority [7]. The check is one refresh call: compare the `refresh_token` in the response against the one you sent, and if it came back changed, every refresh from then on is a state mutation you have to serialise [8]. If it came back identical, you are chasing one of the three steady causes instead [5].
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
The prescribed fix is to make refresh a single-flight operation per integration and to stop treating access token expiry as something every worker discovers independently.
In the worked race, worker A reads the row, sees expires_at in the past and POSTs; worker B reads the same row 40 ms later, sees the same stale expires_at and POSTs the same refresh token; A gets a new pair, B presents an already-redeemed token and gets invalid_grant, and depending on the provider the family is revoked and A's brand-new token is dead too.
The sample code sets REFRESH_SKEW to 120 seconds and takes a fast path that returns the stored access token with no lock and no network call when expires_at minus the skew is still in the future.
Inside the transaction the sample sets lock_timeout to 15s, calls pg_advisory_xact_lock with a stable 64-bit signed key derived from a SHA-256 hash of the integration id, then re-reads the token row inside the lock because someone may have refreshed while it waited.
The OAuth 2.0 spec assigns invalid_grant to any grant that is invalid, expired, revoked, does not match the redirect URI, or was issued to another client, so providers pile several unrelated conditions into one error string.
On a refresh call, invalid_grant means one of four things: the refresh token expired through idle-expiry, the user or an admin revoked access, the client credentials do not match the token, or the refresh token was already used once and the provider rotates.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Self-consistent single-source explainer with spec citations but no data
The mechanism is coherently argued and anchored to citable standards work (the OAuth 2.0 error definition, the Security BCP rotation recommendation, OAuth 2.1 drafts) and to concrete, inspectable artefacts: a table definition and a Python refresh function with explicit skew and lock timeouts. Against that, the cluster contains one dev.to post by a single author, no independent corroboration, no named provider behaviour, and no telemetry, incident counts, or before/after measurements to support the prevalence and family-revocation assertions.
No adoption signal in supplied sources
The supplied source is an explainer with sample code. It contains no releases, deployments, usage disclosures, benchmarks, download or star counts, or reports of teams having shipped this pattern, so no adoption level can be measured without inference.
Diagnosis stated more confidently than the supplied evidence carries
Modestly overstated. The engineering content is sober - it names its own drawback of holding a transaction across an HTTP call and hedges provider behaviour - but two load-bearing assertions outrun what the post shows: that concurrency is 'usually' the cause of recurring invalid_grant, and that 'many providers' revoke an entire token family on reuse. Neither is supported by named providers or measurements, and the post elsewhere softens the same point to 'depending on the provider'.
No disclosed commercial interest to assess
The supplied text is a technical post on a community publishing platform with no product pitch, vendor attribution, sponsorship, affiliation, or funding disclosure visible. There is not enough in the source to characterise the author's or publisher's incentives without inventing facts.
Mechanism credible, generality unverified
Confidence is moderate. The described race - stale expires_at read by multiple workers, concurrent redemption of one rotating refresh token, reuse detection firing - is internally consistent, standards-aligned, and the proposed serialisation is a well-understood Postgres primitive, so the mechanism is likely correct where it applies. Confidence is held down by the single-source cluster, the absence of any adoption or measurement evidence, and unverified claims about how commonly providers rotate and revoke families.
build
Return the admission record, not the log line: one memory service's case for receipts1 distinct publisher
build
Your agent's retry logic is reading a timeout as a fact it does not have1 distinct publisher
build
isinstance(amount, (int, float)) is not a number check: NaN walks through a withdrawal guard1 distinct publisher
build
Stop inheriting your timeout: 100 streamed requests will tell you what the budget should be1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 24, 2026