OAuth · beginner

How to verify OAuth state is bound to the initiating browser session

OAuth's `state` parameter is a per-flow nonce that must be bound to the browser session that initiated the flow; this guide explains how to verify that binding before accepting any authorization callback, using cookie-scoped storage and explicit comparison.

The symptoms

  • The callback handler accepts authorization codes whose `state` value does not match the value originally stored for the current browser session, indicating the binding check was skipped or stored in a shared location.
  • Two independent sign-in attempts launched in different browser tabs or windows succeed against the same backend because the `state` was kept in a global cache rather than a session-scoped store.
  • After a server restart, redeploy, or horizontal scale-out, every pending callback is accepted because `state` was held in process memory and the comparison degraded to an empty-string match.
  • Log entries show authorization callbacks arriving with a `state` that corresponds to a flow started by a different user identifier, cookie, or origin than the one now presenting the callback.

Likely causes

  • Storing the expected `state` value in a server-wide singleton, in-memory map keyed only by user, or a shared key-value store without per-session namespacing.
  • Returning the `state` comparison result based on presence rather than equality, so an unset server-side value silently matches a missing client-side value.
  • Mixing flows across multiple OAuth clients or providers whose `state` namespaces collide because they share a single storage key.
  • Reading the `state` cookie before the cookie scope attributes have been validated, so a sibling domain or parent path supplies a `state` that the handler accepts.
  • Trusting a `state` value supplied in the callback URL fragment or query string without comparing it against a server-held, session-bound copy generated at initiation.

First ten minutes

  1. 01Capture one full authorization round-trip in the affected environment: record the outgoing authorize request including its `state` parameter and the incoming redirect to the callback handler including the `state` returned by the provider.
  2. 02Confirm which cookie, session store, or backend record holds the expected `state`, and confirm whether that store is keyed by browser session identifier or by something broader such as user id or application instance.
  3. 03Compare the initiating request's `state` to the callback's `state` and to the server-held value; classify the mismatch as wrong-scope, wrong-session, expired, or simply absent.
  4. 04Inspect Set-Cookie attributes on the response that issued the `state` so you can confirm Path, Domain, Secure, HttpOnly, and SameSite are scoped to the initiating origin and not readable by sibling flows.
  5. 05Replay the comparison in isolation with a deliberately mismatched `state` to confirm the handler rejects it, then with a matching value to confirm acceptance, so the check itself is proven to be wired up.

Evidence to collect

  • The exact `state` value sent on the outgoing authorize request and the exact `state` value returned on the callback, captured from network traces or application logs at the handler boundary.
  • The session identifier used as the key under which the expected `state` is stored, and the lifetime policy applied to that entry.
  • Set-Cookie header field values for the response that established the session-bound `state` cookie, recorded with the request that triggered them.
  • A trace showing the comparison outcome for both a known-good and a known-bad `state`, with the precise code path that produced each result.

Where to look

  • The application route that issues the authorize redirect, specifically where the `state` value is generated and where it is persisted against the current browser session.
  • The callback handler that receives the redirect from the authorization server, specifically the code path that extracts `state` from the query string and compares it to the persisted value.
  • The session storage layer where the expected `state` is held, including its keying scheme, namespace, and eviction policy.
  • The HTTP response headers issued when the session is established, with attention to Set-Cookie attribute fields that define where the `state` cookie is visible.

Diagnostic steps

  1. 01Generate a fresh `state` value at initiation, persist it keyed by the current session id, and verify the key combines session id with the OAuth client id so collisions across flows are impossible.
  2. 02On the callback, read `state` from the query parameters, look up the expected value using the current session id, and require an exact, constant-time string equality before proceeding.
  3. 03Distinguish four outcomes during diagnosis: match-accept, mismatch-reject, missing-session-reject, and expired-session-reject, and record which path each observed callback took.
  4. 04Inspect the Set-Cookie response that establishes the `state` cookie and confirm Path is restricted to the callback route, Domain is the initiating host, Secure is set on HTTPS, HttpOnly is set, and SameSite is at least Lax.
  5. 05Cross-check whether any middleware rewrites, normalizes, or strips the `state` parameter between issuance and callback, since transformations can cause two visually identical values to compare unequal.
  6. 06Confirm the comparison is performed before any token exchange or user-association step, so a failed binding cannot leak side effects such as account creation or session linkage.

Common mistakes

  • Comparing the callback's `state` to a value stored in a global variable or static field rather than in a store keyed by the presenting browser session.
  • Treating an empty server-held `state` and an empty callback `state` as equal, which silently accepts callbacks that arrive without a `state` parameter.
  • Using the user identifier as the `state` storage key, which collapses all sessions for that user into one bucket and accepts any of their other flows' callbacks.
  • Persisting `state` in process memory and assuming it survives a restart, redeploy, or replica failover, after which the comparison degrades to a no-op.
  • Reading the `state` cookie with overly broad Path or Domain settings so a callback hosted on a sibling route or subdomain reads a different flow's value.

Safe fixes

  • Bind the expected `state` to the browser session by keying its storage record on a session id derived from a cookie scoped to the initiating origin, and reject any callback whose session id cannot be resolved.
  • Perform exact, case-sensitive equality between the callback's `state` and the server-held value, reject when either side is missing or empty, and never fall back to accepting an absent value.
  • Namespace the `state` storage by OAuth client id and provider so concurrent flows from different applications cannot collide on the same key.
  • Issue the session cookie with Path restricted to the callback route, Domain set to the initiating host, Secure on HTTPS, HttpOnly enabled, and SameSite set to Lax or stricter, per the Set-Cookie contract.
  • Apply a short, fixed lifetime to the stored `state` so a delayed callback cannot be replayed against a still-valid session, and invalidate the entry as soon as a matching callback is consumed.
  • Move the binding check ahead of any token exchange, user lookup, or account-association logic so a rejected callback leaves no persistent side effects.

Prove the fix

  1. 01Initiate a flow, then submit a callback whose `state` parameter has been altered by a single character; the handler must reject it before any token exchange and must not create or modify any server-side user record.
  2. 02Initiate a second flow in a different browser session and submit its callback to the first session's handler; the handler must reject it and the rejection must be attributable to a session-id mismatch, not to a missing value.
  3. 03Wait until the stored `state` lifetime has elapsed, then replay the original callback; the handler must reject it as expired and must not consume a token.
  4. 04Restart or re-route the application between initiation and callback so the in-memory store is empty; the handler must reject the callback as missing-session rather than accept it on an empty comparison.
  5. 05Inspect Set-Cookie on the response that establishes the `state` cookie and confirm Path, Domain, Secure, HttpOnly, and SameSite remain within the documented constraints defined for the Set-Cookie header field.

Prevention and next steps

  • Treat `state` storage as a per-session, per-client resource with an explicit namespace and a short lifetime, and review that keying scheme whenever a new OAuth client or provider is onboarded.
  • Add a regression test that feeds the callback handler a tampered `state`, a missing `state`, and a cross-session `state`, and assert rejection in all three cases before each release.
  • Audit Set-Cookie attributes on every response that establishes a session-bound token, and keep those attributes within the documented Set-Cookie header field semantics.
  • Place the binding comparison before any persistent side effect in the callback handler so a future code change cannot accidentally accept an unbound callback.

Safe commands and checks

grep -n "state" <callback-handler-source-file>
grep -rn "state" <session-store-source-directory>
grep -n "Set-Cookie" <auth-route-source-file>