An OAuth callback is often treated as the last, boring step of login. The user returns from the identity provider, the application reads a code, and a session is created. That mental model is too small.
The callback is where an application decides whether a response belongs to the login attempt that started in this browser. It needs a proof of intent, not just a valid-looking authorization code. This matters for normal web apps, admin tools, and test environments that use disposable mail for account setup. A dummy e mail address can be useful in a test, but it does not make a callback trustworthy by itself.
The callback must prove intent
OAuth has several values that look like ordinary request parameters: state, code, and sometimes an error response. They are not interchangeable.
-
statehelps bind the callback to the browser session that initiated it. - PKCE binds the authorization request to a verifier held by the client.
- The authorization code is short-lived material that should be exchanged once.
- The redirect URI limits where the provider may send the response.
A callback can look correct and still be wrong, specially when the application accepts a code without checking which session started the flow. An attacker does not always need to steal a password. Sometimes the trick is to make a victim's browser finish an authorization flow that was started somewhere else.
A small threat model
Before writing middleware, I write down the unwanted outcomes. This keeps the review practical instead of turning OAuth into a list of acronyms.
- A callback response is injected into a different browser session.
- A code is replayed after the first exchange or sent to the wrong redirect URI.
- A state value is accepted without proving that the server issued it.
- Logs expose an authorization code, access token, email address, or raw state value.
- A successful provider response is attached to the wrong local account.
The first three are request-integrity problems. The fourth is a privacy problem. The fifth is an account-linking problem, and it can be the most damaging because the login may appear successful to the user. These risks occur more often then teams expect when callback code is copied from a quick prototype.
Bind state and PKCE to the initiating session
At the start of the flow, generate a high-entropy state value and, where supported, a PKCE verifier. Store only what is needed in the server-side session or a short-lived encrypted cookie. The stored record should include an expiry, the provider name, the redirect URI, and a one-time-use marker.
type OAuthAttempt = {
stateHash: string;
codeVerifier: string;
provider: "example-idp";
redirectUri: string;
expiresAt: number;
used: boolean;
};
// Store the hash when practical; keep the verifier server-side for the exchange.
req.session.oauthAttempt = {
stateHash: sha256(state),
codeVerifier,
provider: "example-idp",
redirectUri: EXPECTED_REDIRECT_URI,
expiresAt: Date.now() + 5 * 60_000,
used: false,
};
On the callback, hash the received state and compare it with a constant-time comparison. Check the expiry and the provider before exchanging the code. If the identity provider is slow, the callback still need to fail closed once the attempt has expired. It is better to ask the user to start again than to quietly relax the boundary.
The used flag should be consumed atomically. A read followed by a separate update can allow two concurrent requests to exchange the same attempt. This is a small race, but security bugs often hide in small races.
Validate the callback before creating a session
The safe order is more important than clever abstractions:
- Confirm that the callback belongs to the expected provider and route.
- Reject an error response unless it is tied to the expected state.
- Validate state, expiry, and one-time use.
- Exchange the code with the exact redirect URI and PKCE verifier.
- Validate the issuer, audience, nonce when applicable, and token claims.
- Map the provider identity to a local account using a stable subject identifier.
- Create the local session only after every check succeeds.
Do not use the returned email address as the only account key. Email can change, be unverified, or be normalized differently by two systems. The provider subject plus the provider issuer is a much safer identity pair.
This is also where test design helps. If a browser test uses a redaction boundary for OAuth callback logs, it becomes easier to assert that failures contain a correlation ID without requiring secret values. For operational follow-up, a release alert should be specific enough to tie an alert to one rollout, while still keeping user data out of the alert body.
Logging without leaking credentials
Useful callback logs answer: which request, which provider, which outcome, and how long the exchange took. They do not need the raw code, state, nonce, access token, or complete redirect URL.
I normally log a random correlation ID, a short event name such as oauth_callback_state_mismatch, and a reason category. Hashing a value for correlation can be appropriate, but remember that a low-entropy value may still be guessable. Do not log the full provider response while debugging, even briefly; temporary debug logs have a way of staying in production.
For local email verification tests, a tempmail so inbox can help isolate account creation from a personal mailbox. Keep that test identity separate from OAuth security assertions: receiving an email proves delivery, not that the callback belongs to the initiating session.
A practical review checklist
Before shipping an OAuth callback, check:
- Is state generated per attempt and bound to the initiating session?
- Is comparison constant-time and is the attempt single-use?
- Are expiry, provider, issuer, audience, nonce, and redirect URI checked?
- Is PKCE used when the client and provider support it?
- Is the code exchanged with the same redirect URI that started the flow?
- Is the local account keyed by issuer plus stable subject?
- Are tokens, codes, emails, and raw query strings absent from logs?
- Do tests cover a replay, a wrong-session callback, a stale state, and a mismatched redirect URI?
The goal is not to make OAuth mysterious. It is to make the callback explainable: this response was issued for this attempt, returned to this route, exchanged once, and attached to this identity. That is a much stronger boundary than simply checking whether a code exists.
Top comments (0)