Short answer: make the authorization request repeatable by login-intent ID, make callback consumption single-use, and treat an uncertain token exchange as a fresh login. For a developer-tools app adding phone one-time-code login as a recovery path, this keeps a browser retry from binding the wrong identity to a session.
Start with a decision table, not a retry loop:
| Boundary | Store | Safe response to a duplicate or timeout |
|---|---|---|
| Authorization start | Intent ID, state hash, PKCE verifier hash, expiry | Return the existing authorization request while the intent is valid |
| Callback validation | State, redirect URI, intent status | Reject and require a new intent; never “try harder” |
| Code exchange | Exchange status and attempt ID | Retry only before transmission or with a documented replay guarantee |
| Session and phone-code recovery | Session result and recovery-attempt ID | Return the recorded result, or start a new bounded attempt |
That table is the field guide. A refresh should be boring.
How should OAuth failure recovery connect authorization, callback, and phone-code steps?
Model a login as an expiring intent. At authorization start, create an intent ID and store hashes of state and the PKCE verifier with the browser session, redirect URI, provider alias, and expiry. Send the raw state and verifier-derived challenge to the authorization server. The callback must match the stored values before any code exchange.
The handoff is a one-way state machine: created -> callback_received -> code_exchanged -> session_issued. expired and rejected are terminal. A compare-and-set operation owns each transition, so two callbacks cannot both claim one code. If the user reaches the phone one-time-code screen after an OAuth rejection, give that recovery attempt its own ID and expiry; do not reuse the OAuth state as an OTP credential.
Here is the provider-neutral boundary. The repository methods stand for transactional operations in your datastore.
type AttemptStatus = "created" | "callback_received" | "code_exchanged" | "session_issued" | "expired" | "rejected";
interface LoginAttempt {
id: string;
stateHash: string;
verifierHash: string;
redirectUri: string;
expiresAt: number;
status: AttemptStatus;
callbackResult?: { userId: string; sessionId: string };
}
async function handleCallback(input: { code: string; state: string }, now = Date.now()) {
const attempt = await attempts.findByStateHash(hash(input.state));
if (!attempt || attempt.expiresAt <= now) return { kind: "relogin" as const };
const claimed = await attempts.compareAndSet(attempt.id, "created", "callback_received");
if (!claimed) {
const existing = await attempts.get(attempt.id);
return existing?.callbackResult
? { kind: "ok" as const, ...existing.callbackResult }
: { kind: "pending" as const };
}
try {
const token = await exchangeCodeOnce({
code: input.code,
verifierHash: attempt.verifierHash,
redirectUri: attempt.redirectUri,
});
await attempts.compareAndSet(attempt.id, "callback_received", "code_exchanged");
const session = await sessions.createIdempotent(attempt.id, token.subject);
await attempts.finish(attempt.id, { userId: token.subject, sessionId: session.id });
return { kind: "ok" as const, userId: token.subject, sessionId: session.id };
} catch (error) {
if (isTransportFailureBeforeSend(error)) {
await attempts.resetForExchangeRetry(attempt.id);
return { kind: "retryable" as const };
}
await attempts.reject(attempt.id);
return { kind: "relogin" as const };
}
}
The important branch is the timeout after transmission. You cannot infer that the authorization server failed to consume the code. Unless its contract supplies an idempotency key or explicitly permits replay, mark the exchange uncertain and ask for a new login. A blind retry can turn an ambiguous network event into invalid_grant.
What should a retry matrix test before an OAuth rollout?
Test the transitions against real redirect domains, cookie policy, and clock skew. The minimum matrix includes an expired state, mismatched state, reused code, duplicate callback, timeout after request transmission, a user closing the tab, and a phone-code fallback that arrives after the OAuth intent has expired. Five minutes of state lifetime is not meaningful if application clocks disagree by 90 seconds.
I once traced a 504 retry loop that created 17 callback records for one browser session. The useful signal was the repeated attempt ID, not the upstream response body. Log that ID, provider alias, validation result, transition, latency, and normalized error class. Do not log authorization codes, access tokens, PKCE verifiers, OTP values, or complete callback query strings.
Metrics should describe behavior without retaining secrets: oauth_callback_replay_total, oauth_state_reject_total, exchange latency, and phone_recovery_start_total are enough to find a pattern. Alert on a change in rejection rate, then inspect a correlation ID in the trace. Your mileage may vary with providers that rotate refresh tokens or add consent screens; expose those capabilities in an adapter instead of hiding them behind a boolean called oauthCompatible.
Which retry policy belongs at each boundary?
Authorization start is normally safe to retry when keyed by an application login-intent ID. Return the same authorization URL while the intent is valid. Callback validation is not retryable: a bad or expired state needs a fresh login. Code exchange is conditional. Retry only when the request was not sent, or when the provider documents replay safety. Session creation is idempotent on the attempt ID.
Use bounded backoff with jitter for transport operations and stop at the user-facing deadline. A three-attempt schedule such as 200 ms, 500 ms, and 1.2 s is a starting point, not a standard; tune it from observed latency and the login SLO. Return stable error categories to the browser so frontend code does not parse provider-specific strings.
Three words: fail closed.
Keep the callback handler short. Redirect to a result page that can poll the attempt status instead of letting a slow exchange make the browser resubmit the callback. For the phone fallback, rate-limit code requests, bind verification to the recovery-attempt ID, and consume a successful code exactly once. Those controls address different replay surfaces; combining them into one generic retry counter hides the risk.
Limits, trade-offs, and the handoff to operations
This approach is not suitable when an identity provider cannot preserve a stable state and PKCE flow, or when policy requires a hosted journey your adapter cannot represent. Keep that managed integration and enforce strict callback validation. A self-hosted authorization server offers more control over retry semantics and data location, but it adds key rotation, consent UX, incident response, and patching to the team.
The catch is that safe retries reduce ambiguity; they do not make an unavailable provider available. You still need a user-visible recovery path, an on-call alert for rising rejection rates, and a runbook for rotating redirect credentials. I'm not sure one retry number can fit every provider, so I would tune the deadline from production traces and write down why each limit exists.
The release gate is concrete: one owner per intent, one callback claim, no secrets in logs, no automatic replay after an uncertain exchange, and a phone-code recovery attempt that cannot be confused with OAuth state.
Top comments (0)