Short answer: choose providers by their protocol fit and account identifiers, then make the callback a one-use transaction that ends in your own short-lived local session.
That sounds obvious until a shopper clicks Pay in a second tab while an attacker replays the first callback. Session security and checkout friction pull in opposite directions. I care about the first successful API call, so I keep the pipeline small: six checks, one transaction record, and no configuration maze.
What should an OAuth callback pipeline verify before creating a local session?
The callback is not a login button. It is an untrusted browser return carrying two values: code and state. Treat both as single-use inputs. The server should look up the pending transaction by a hash of state, verify that it has not expired or been consumed, and compare the stored redirect URI and provider identifier with the request it is handling. A mismatch is a clean rejection, not a best-effort login.
For a public client, add PKCE. Generate a verifier with enough entropy, send its challenge to the authorization endpoint, and keep the verifier server-side (or in a tightly bound, short-lived browser transaction). RFC 7636 exists because a stolen authorization code must not be useful without the verifier. Do not accept a provider's email address as your account key. Use the provider plus its stable subject identifier, and keep email only as profile data subject to your account-linking policy.
I once assumed that checking state alone covered replay. It did not cover a code sent to the wrong redirect URI, and the missing provider binding made test fixtures dangerously permissive. A 15-minute expiry and an atomic consumed_at write fixed the shape of the problem. The exact timeout is a policy choice; your mileage may vary with mobile handoff latency.
The six guards are:
- Exact redirect URI match, including scheme, host, path, and registered port.
- Cryptographically random
state, stored with the transaction. - PKCE verifier validation where the client type or provider requires it.
- One-time code consumption with an atomic database update.
- Issuer, audience, signature, and nonce checks on an ID token when OpenID Connect is used.
- A local session cookie that is
HttpOnly,Secure, and appropriatelySameSite, with rotation after privilege changes.
The provider selection decision follows from these checks. Pick an issuer with documented discovery metadata, stable subject identifiers, PKCE support, and a revocation story you can operate. A flashy profile API does not compensate for an unclear token lifetime.
The smallest TypeScript implementation I would ship
The example below leaves the provider adapter deliberately boring. Each adapter must implement the same contract, while the callback owns security decisions and local account mapping. Secrets belong in a secret manager; the sample uses environment variables only to keep the flow visible.
type OAuthProvider = {
issuer: string;
clientId: string;
clientSecret: string;
redirectUri: string;
exchangeCode(input: { code: string; verifier: string; redirectUri: string }): Promise<{
accessToken: string;
idToken?: string;
}>;
verifyIdentity(tokens: { accessToken: string; idToken?: string }): Promise<{
subject: string;
email?: string;
}>;
};
type PendingLogin = {
stateHash: string;
provider: string;
redirectUri: string;
codeVerifier: string;
expiresAt: number;
consumedAt: number | null;
};
type Session = {
accountId: string;
sessionId: string;
createdAt: number;
};
async function handleOAuthCallback(
query: { code?: string; state?: string },
pending: PendingLogin,
provider: OAuthProvider,
consumeOnce: (stateHash: string) => Promise<boolean>,
findOrCreateAccount: (identity: { provider: string; subject: string; email?: string }) => Promise<string>,
createSession: (session: Session) => Promise<string>,
): Promise<string> {
if (!query.code || !query.state) throw new Error("invalid_callback");
if (Date.now() > pending.expiresAt) throw new Error("expired_callback");
const stateHash = await sha256(query.state);
if (stateHash !== pending.stateHash) throw new Error("state_mismatch");
if (!(await consumeOnce(stateHash))) throw new Error("callback_replayed");
const tokens = await provider.exchangeCode({
code: query.code,
verifier: pending.codeVerifier,
redirectUri: pending.redirectUri,
});
const identity = await provider.verifyIdentity(tokens);
const accountId = await findOrCreateAccount({
provider: pending.provider,
subject: identity.subject,
email: identity.email,
});
const sessionId = crypto.randomUUID();
return createSession({ accountId, sessionId, createdAt: Date.now() });
}
async function sha256(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Buffer.from(digest).toString("base64url");
}
consumeOnce must be backed by a conditional update such as UPDATE oauth_login SET consumed_at = now() WHERE state_hash = ? AND consumed_at IS NULL. Check the affected-row count. A read followed by a write creates a race window exactly where a replay attacker wants one.
The callback should redirect to a neutral success route after setting the session cookie. Never put access tokens, identity claims, or raw error details in the URL. Log a correlation ID, provider name, and rejection reason category; redact codes and tokens.
Where does session revocation fit after a stolen refresh token?
Treat refresh-token rotation and browser-session revocation as related but separate records. Store a hash of the current refresh token with a family ID, account ID, issued-at time, and replaced-at time. On refresh, accept the current token once, mark it replaced, and issue a new token. Seeing an already replaced token is a family-level compromise signal: revoke every active session in that family and require a fresh OAuth handoff.
For a stolen local session cookie, increment an account or session revocation counter and remove the server-side session row. Keep the cookie value opaque. A five-minute access-token lifetime can limit API exposure, but it does not replace server-side revocation because a still-valid cookie can continue to call your application.
The difficult part is user friction. Revoking every device after one suspicious refresh protects the account but interrupts a shopper's cart on their phone and laptop. A device-bound session record gives you a narrower response; the catch is that device signals are probabilistic and can be reset by browsers. Make the policy explicit: high-risk evidence revokes the family, low-confidence evidence asks for step-up authentication.
How should provider selection, callback testing, and local session policy work together?
Run conformance tests against every provider adapter. Test an expired state, a mismatched redirect URI, a wrong PKCE verifier, a duplicate callback, an issuer mismatch, and an identity whose subject changed while the email stayed constant. Then run a browser test that opens two callback tabs and confirms only one creates a session. These tests catch wiring errors before a checkout campaign does.
I benchmark the boring parts: median callback latency, p95 token exchange latency, database contention on consumeOnce, and the percentage of callbacks abandoned after provider consent. Track rejected states by reason, never by secret value. A sudden rise in callback_replayed is an incident signal; a rise in expired_callback may just mean a mobile app needs a longer, still bounded window.
Keep the adapter boundary narrow.
At scale, I would move pending transactions and session records into a shared store, add a per-provider circuit budget, and make revocation events observable to every API that accepts the session. I would also add key rotation drills for token verification and a recovery path for an account whose provider is unavailable. Those additions cost operational work, so a small shop may reasonably keep one provider and a single-region store until the threat model demands more.
Here is the trade-off I use when reviewing a design:
| Choice | Security benefit | Friction or cost |
|---|---|---|
| One provider, strict checks | Small attack surface and low glue | Provider outage blocks new sign-ins |
| Multiple providers, one account key | More entry points and recovery options | Linking and subject-mapping policy gets harder |
| Server-side sessions | Immediate revocation and small cookies | Shared storage and cleanup jobs |
| Self-contained signed sessions | Fewer reads on each request | Revocation needs short TTLs or a deny list |
This pipeline is not suitable when you need workforce SSO policy, SCIM lifecycle management, or a provider-hosted consent and audit program. Use an identity platform with those controls when they are requirements, and keep the same callback invariants at your application boundary. Stick with a single, well-tested provider when your team cannot staff multi-provider incident response.
A local session is the product boundary. OAuth is only the handoff.
Top comments (0)