A good retry rule is simple: replay a failed token exchange only when the authorization code is still single-use safe; otherwise start a new authorization transaction and preserve the original risk context. For a B2B SaaS that scores login risk from device fingerprints, this keeps a transient network timeout from becoming either a login loop or a security downgrade.
Short answer: make authorization attempts transactional, retry transport failures with an idempotency key, and treat callback state, code reuse, and provider denials as terminal for that attempt.
The decision is about abuse resistance first. A retry that silently skips state validation can turn a harmless timeout into account takeover. A retry that always sends the user back to the identity provider creates friction and gives bots more chances to probe your risk endpoint.
The choice: replay or restart?
| Failure observed | Safe action | Why |
|---|---|---|
| Timeout before the provider receives the request | Reuse the same authorization URL once | The browser may never have left your system. |
| Timeout after code exchange was sent | Do not replay the code; query your transaction record and ask for a fresh code | Authorization codes are single-use by design. |
invalid_grant, access_denied, or state mismatch |
End the attempt and show a recovery path | These responses carry security meaning, not transport noise. |
| Callback arrives twice with the same code | Return the stored result for that transaction | Duplicate delivery is normal in distributed systems. |
The useful unit is an authorization transaction, not an HTTP request. When the token endpoint times out, the ambiguity matters: the provider may have issued tokens even though your process saw no response. I record the outbound attempt before sending it, attach a deadline, and let a worker reconcile the transaction after the deadline rather than immediately issuing another code. That reconciliation can inspect only the provider response class and local state; it never needs the secret itself. If the record says the exchange was accepted, the callback resumes from the stored result. If the record is still unknown when the authorization window closes, the user gets a new state and PKCE verifier. This is slower than blindly retrying, but it gives the abuse team a clean audit trail and keeps a bot from turning ambiguous network behavior into unlimited code guesses. Store a random transaction ID, a hash of the expected state, the PKCE verifier, provider name, creation time, and a risk snapshot for the device fingerprint. Mark it created, callback_received, exchange_pending, succeeded, or failed. A monotonic transition prevents a second callback from running the exchange again.
I keep the user-facing decision separate from provider tokens. The risk snapshot might say “new device, ASN changed, impossible travel signal,” while the token record says only whether the provider exchange completed. That separation means a retry cannot accidentally replace a high-risk assessment with a fresh, empty one.
How should authorization and callback retries handle state and codes?
Start with a bounded budget. One browser redirect retry and one back-channel exchange retry are enough for most login flows. Exponential backoff belongs around network errors, with jitter and a deadline shorter than the transaction's expiry. Never back off on invalid_grant, invalid_client, unauthorized_client, access_denied, or a failed state comparison.
The callback handler should be boring. It validates the exact redirect URI, compares state in constant time, verifies the PKCE code verifier, and then claims the transaction atomically. If another worker already claimed it, the handler returns the recorded outcome instead of touching the provider again.
Here is a provider-neutral TypeScript sketch. The storage methods represent your database transaction; they are intentionally generic so the retry policy remains testable.
type OAuthFailure =
| { kind: 'transport'; retryable: true; cause: unknown }
| { kind: 'provider'; code: string; retryable: false }
| { kind: 'security'; reason: string; retryable: false };
type Attempt = {
id: string;
stateHash: string;
pkceVerifier: string;
riskSnapshot: { fingerprintHash: string; score: number };
status: 'created' | 'exchange_pending' | 'succeeded' | 'failed';
exchangeTries: number;
};
async function handleCallback(
input: { code?: string; state?: string; attemptId: string },
store: {
load(id: string): Promise<Attempt | null>;
claimForExchange(id: string): Promise<boolean>;
saveFailure(id: string, reason: string): Promise<void>;
saveSuccess(id: string, tokens: unknown): Promise<void>;
},
exchange: (code: string, verifier: string) => Promise<unknown>,
hash: (value: string) => string,
): Promise<'retry-later' | 'rejected' | 'ok'> {
const attempt = await store.load(input.attemptId);
if (!attempt || !input.code || !input.state) return 'rejected';
if (hash(input.state) !== attempt.stateHash) {
await store.saveFailure(attempt.id, 'state_mismatch');
return 'rejected';
}
const claimed = await store.claimForExchange(attempt.id);
if (!claimed) return attempt.status === 'succeeded' ? 'ok' : 'retry-later';
try {
const tokens = await exchange(input.code, attempt.pkceVerifier);
await store.saveSuccess(attempt.id, tokens);
return 'ok';
} catch (error) {
const failure = classifyOAuthError(error);
if (failure.kind === 'transport' && attempt.exchangeTries < 1) return 'retry-later';
await store.saveFailure(attempt.id, failure.kind === 'provider' ? failure.code : failure.reason);
return 'rejected';
}
}
classifyOAuthError must be conservative. If the client cannot tell whether the provider received the request, classify the exchange as ambiguous and require a fresh authorization code after checking the transaction record. A duplicate callback should be idempotent; a duplicate code exchange should not be.
Device risk changes the recovery UX
A risk engine should see recovery as another signal. Keep the original fingerprint hash and browser binding on the transaction. If the callback returns from a different fingerprint, do not “helpfully” transfer the pending attempt. Ask the user to restart, and require the normal step-up factor for that tenant. The message can be plain: “This sign-in expired. Start again.”
For low-risk transport failures, preserve the selected organization and return URL so the second attempt feels continuous. For a state mismatch or a provider denial, discard the code and issue a new state value. Reusing state across attempts makes logs harder to interpret and expands the replay window.
I log an opaque attempt ID, provider error class, latency, retry count, and risk decision. I do not log authorization codes, refresh tokens, raw fingerprints, or complete callback URLs. A 401 from the provider is useful telemetry; the secret that caused it is not.
Testing the ugly paths
Most OAuth tests cover the happy redirect and stop. Add deterministic cases for a dropped TCP response after the exchange, two callbacks arriving within the same millisecond, a mismatched state, an expired transaction, and a callback with a changed device fingerprint. Assert both the database transition and the user-visible outcome.
Use property-based tests for callback ordering: any permutation of duplicate callbacks should produce one token write and one risk decision. In staging, inject latency and connection resets around the token endpoint. I am not sure every identity provider documents its timeout semantics consistently, so your adapter should expose an “ambiguous result” state instead of guessing.
Set alerts on rising state_mismatch, invalid_grant, and retry-exhausted counts, segmented by provider and tenant. A spike in one tenant can indicate an integration configuration issue; a spike across tenants can indicate an attack or an upstream change.
When a fresh flow is the better trade
The catch is that replay safety has limits. Start a fresh authorization flow when the code may have been consumed, the transaction is older than its expiry, the browser binding changed, or the provider returned a semantic denial. Do not spend retries trying to turn an authorization decision into a network problem.
A vendor-managed identity flow can be a reasonable choice when your team cannot operate state storage, PKCE validation, and audit logging. A self-hosted or standards-first adapter fits better when you need strict data residency, custom device signals, or deterministic replay tests. Neither choice removes the need for transaction state and a clear failure taxonomy.
Ship the boundary.
For a solo SaaS, this is a revenue-per-hour decision. Outsource the undifferentiated token plumbing when it buys back shipping time, but keep the policy boundary in your code: which failures are retryable, which signals raise login risk, and when a user must start over. Ship that boundary with metrics, then revisit it as provider behavior and abuse patterns change.
Top comments (0)