An OAuth callback can look valid even when it belongs to the wrong browser interaction. The authorization server may have issued a real code, the redirect URI may be correct, and the code exchange may succeed. Yet if the client cannot tell whether this browser actually started that authorization flow, it can attach the wrong external identity or authorization to the current session.
This is a form of cross-site request forgery at the OAuth callback. In a login flow, one possible consequence is login CSRF: a victim can end up signed into the client as an account associated with someone else. The details vary by application, but the defensive question stays the same: does this callback belong to a login transaction that this user agent started?
The useful mental model is to treat every OAuth authorization attempt as a short-lived pending transaction. Create that transaction before redirecting the browser, bind the callback to it, consume it once, and reject callbacks that cannot prove that relationship.
A valid authorization response is not enough
Consider a site that offers “Sign in with Example Identity.” A simplified authorization-code flow looks like this:
browser client authorization server
| | |
| start login | |
|------------------>| |
| | authorization request |
| |------------------------>|
| | |
|<---------------- browser redirect ----------|
| | |
| callback with code| |
|------------------>| |
| | exchange code |
| |------------------------>|The authorization code tells the client that the authorization server issued a result. By itself, it does not necessarily prove that the callback belongs to the login attempt currently represented by this browser session.
That distinction matters because the callback endpoint is reachable through the browser. If the client accepts any otherwise valid callback and immediately associates its result with whichever local session happens to receive it, two separate security contexts have been mixed together:
- the authorization transaction at the external authorization server;
- the local browser session at the client.
The defense is to create an explicit relationship between them before the browser leaves the client.
Model OAuth login as a pending transaction
When a user starts OAuth login, the client already knows useful context. It knows that a login was requested, which authorization server will be used, and which local browser session initiated the request. Depending on the application, it may also know the intended post-login destination or other non-sensitive workflow state.
Store that information as a pending transaction with a short lifetime. Conceptually:
pending transaction
id: random unpredictable value
browser_session: current session
authorization_server: expected issuer
created_at: current time
return_to: validated local destination
consumed: falseThe exact storage design depends on the application. A server-side record keyed by a random value is often easy to reason about because the browser carries only an opaque identifier while the security-relevant context stays on the server.
The important property is not the table shape. It is the invariant:
A callback is accepted only if it matches a pending authorization transaction created for this user agent and that transaction is still valid.
This turns the callback from an unsolicited event into the completion of a known operation.
Use state to carry the transaction binding
OAuth provides the state parameter for linking an authorization request to its callback. For CSRF protection, the value needs to be non-guessable and bound to the user agent’s authenticated state. Modern OAuth security guidance also requires clients to protect their redirect endpoint against CSRF; a one-time CSRF token in state is the established mechanism when another protocol mechanism is not providing that protection.
A simple server-side design works like this:
1. User starts OAuth login.
2. Client generates a cryptographically random transaction ID.
3. Client stores the pending transaction for the current browser session.
4. Client sends the transaction ID as the OAuth state value.
5. Authorization server returns the same state value in the callback.
6. Client looks up the pending transaction.
7. Client verifies that it belongs to the current browser session.
8. Client consumes it before completing the login.The authorization request might contain a value such as:
state=<opaque-random-transaction-id>The value is not a password, but it is security-sensitive while the transaction is pending. An attacker who learns a usable state value may weaken the binding that the value was meant to provide. Avoid putting unnecessary information into it, avoid logging it casually, and do not make it predictable from user IDs, timestamps, counters, or other public data.
When the callback arrives, compare the returned value with the pending transaction expected for that browser context. A missing, unknown, expired, already-consumed, or incorrectly bound value should make the callback fail rather than fall back to a less strict path.
Bind to the browser session, not just to a database row
A random state value is useful because another party should not be able to guess a pending transaction identifier. Randomness alone is not the whole control.
Suppose the application stores pending transactions globally and accepts any unexpired transaction ID from any browser. If one valid value leaks, a different browser may be able to submit it. The application has proved that the transaction exists, but not that the current user agent is the one that initiated it.
Bind the pending transaction to the local session that started the flow. For a server-side web application, that often means storing an internal session identifier or another server-controlled session reference alongside the transaction. On callback, require both relationships to hold:
returned state -> pending transaction
current session -> same pending transaction ownerDo not put a raw session cookie into state. The client can keep the binding server-side and expose only the random transaction identifier. That avoids turning a CSRF mechanism into another place where a session credential can leak.
Some applications start login before they have an authenticated local user, but they still normally have a temporary browser session or equivalent client-side context. The binding is to the user agent interaction, not necessarily to an already authenticated account.
Make each transaction short-lived and single-use
A pending OAuth transaction should describe one attempt, not become a reusable capability.
Expiration limits how long an abandoned transaction remains acceptable. Choose a lifetime that comfortably covers normal interactive login while keeping stale transactions from accumulating indefinitely. There is no universal duration that fits every application: external identity prompts, multi-factor authentication, slow networks, and accessibility needs can all affect legitimate completion time.
Single-use handling matters for a different reason. Once a callback has successfully claimed a pending transaction, later callbacks carrying the same transaction identifier should not be able to complete it again.
The check and consumption need to behave as one security decision. Conceptually:
consume transaction
where id = returned_state
and session = current_session
and expires_at > now
and consumed = falseOnly the request that successfully changes the transaction from pending to consumed continues. If two callback requests arrive close together, this avoids treating both as first use.
Production implementations can achieve this with a database transaction, conditional update, compare-and-set operation, or another atomic primitive appropriate to the storage system. A separate “check, then later mark used” sequence can create a race window.
Keep application state separate from the security binding
Developers often want OAuth to return users to the page they were viewing before login. It is tempting to encode that destination directly into state, for example as a path or URL.
That creates two different jobs for one field:
- bind the callback to the initiating browser transaction;
- carry application navigation state.
They can coexist, but the security properties become harder to see. A cleaner server-side design is to keep the OAuth state value opaque and store navigation information in the pending transaction:
state = random transaction ID
server-side transaction:
return_to = /projects/42Validate return_to according to the application’s redirect policy when storing or using it. A correct OAuth transaction binding should not accidentally introduce an open redirect after login.
If an application deliberately uses a self-contained state value instead of server-side storage, it needs to protect the integrity of security-relevant contents and still bind them to the browser transaction. Simply base64-encoding JSON does not provide integrity; anyone who can alter the value can alter the decoded fields. Signed or authenticated representations can address tampering, but they do not remove the need for freshness, correct browser binding, and replay handling.
For many server-rendered applications, an opaque random identifier plus server-side state is the simpler design to audit.
Understand what PKCE changes
Proof Key for Code Exchange (PKCE) binds an authorization request to a later token request using a fresh secret called a code verifier. The authorization request carries a derived code challenge; the token request later presents the verifier. This makes a stolen authorization code less useful to a party that does not have the verifier.
Current OAuth security guidance goes further: when a client has ensured that its authorization server supports PKCE, the client may rely on PKCE’s transaction binding for CSRF protection. OpenID Connect flows can also use their nonce mechanism for this purpose under the relevant protocol rules.
That means state is not the only possible CSRF defense in a modern OAuth deployment. The engineering lesson is not “always add another random parameter regardless of the protocol.” It is to identify which mechanism proves that the authorization response belongs to the transaction this client started, and verify that mechanism correctly.
If your application uses state for CSRF protection, implement the binding described in this article. If it intentionally relies on PKCE or an OpenID Connect mechanism instead, verify that the authorization server support and protocol assumptions required by that design are actually enforced. Do not silently drop the binding because a library happens to make state optional.
PKCE also should not be treated as a substitute for every other OAuth check. Redirect URI validation, authorization-server identity, token validation, client authentication where applicable, and application authorization remain separate concerns.
Account for multiple authorization servers
A client that supports more than one authorization server has another question to answer: which server was this transaction started with?
Store the expected authorization server in the pending transaction. When processing the response, apply the protocol’s issuer-validation or mix-up defenses rather than assuming that any configured provider is interchangeable.
Conceptually:
transaction A -> expected authorization server A
transaction B -> expected authorization server BThe browser session binding answers, “Did this browser start this transaction?” The authorization-server binding answers, “Is this response from the server this transaction expected?” Those are related but distinct checks.
This is a good example of why a pending-transaction model scales better than a single loose state comparison. The transaction becomes the place where the client records the security assumptions that must still be true when the browser returns.
Common implementation failures
Several designs look close to correct while weakening the actual guarantee.
Accepting callbacks when state is missing. If state is your chosen CSRF mechanism, treating it as optional creates a bypass path. Error handling should reject the callback rather than continue with reduced checks.
Using a predictable value. A user ID, current timestamp, or hash of public values is not a substitute for a cryptographically random transaction identifier. The binding value needs to resist guessing while it is valid.
Checking only that the transaction exists. Existence does not prove ownership by the current browser session. Verify the session binding as well.
Allowing unlimited reuse. A transaction that remains valid after successful completion can turn a one-time protocol step into a replayable one. Consume it when it is accepted.
Putting secrets into state. The value travels through browser-visible URLs and protocol infrastructure. Use it as a correlation value, not as a container for passwords, access tokens, session cookies, or other credentials.
Trusting an unvalidated return URL stored with the transaction. OAuth CSRF protection and redirect safety are separate controls. Keep post-login navigation on destinations the application intends to use.
Assuming the OAuth library handles everything without checking its contract. Libraries differ in what they store, verify, expire, and consume. Confirm the behavior with documentation and tests rather than inferring it from a successful login.
Test the security property, not just the happy path
A normal end-to-end login test proves that the flow works. It does not prove that unrelated callbacks are rejected.
Add tests around the pending-transaction invariant. A callback should fail when its binding value is missing, unknown, expired, already consumed, or associated with another browser session. If the client supports several authorization servers, test that a transaction started for one cannot be completed as though it belonged to another.
Also test concurrency. Send two completion attempts for the same pending transaction close together and verify that only one can claim it. This catches implementations that validate first and mark the transaction used later.
Finally, inspect operational logs. They should tell you that an OAuth callback failed because its transaction was invalid without recording the full security-sensitive callback URL or reusable credentials. Useful diagnostics and secret minimization can coexist.
Know the boundary of this control
Binding OAuth callbacks to their initiating transaction reduces CSRF-style callback injection and accidental cross-session confusion. It does not make the whole OAuth deployment secure by itself.
It does not compensate for a client that accepts arbitrary redirect URIs, skips TLS verification, mishandles authorization-server identity, leaks authorization codes, accepts invalid tokens, or grants application permissions without authorization checks. It also does not protect a browser session that an attacker has already taken over; an attacker controlling that session may be able to start legitimate-looking transactions from within it.
Think of the control narrowly: it establishes continuity between the authorization attempt the client started and the callback the client is now processing. That continuity closes an important gap, but the rest of the protocol and application still need their own checks.
Make the callback complete a known transaction
The most useful design decision is to stop treating the OAuth callback as a standalone request. It is the second half of a security-sensitive transaction that began before the browser left your application.
Record that transaction, bind it to the user agent, use the protocol’s appropriate CSRF mechanism, expire it, consume it once, and reject callbacks that do not match. Then test the rejection paths as deliberately as the successful login path.
With that model in place, OAuth callback handling becomes easier to reason about: the client is not asking only whether a response looks valid. It is asking whether this is the valid response to this browser’s pending authorization attempt.