Fixing State Mismatch Errors on Callback
A state mismatch means the value your application generated at the start of the login is not the value that came back — and because that is exactly the signal an attack produces, the error is worth understanding rather than working around. This page is part of the authorization code flow with PKCE guide.
What state Is For
Before diagnosing, be precise about the purpose. The state parameter binds the callback to the browser that started the flow. Your server generates a random value, stores it against the pre-login session, sends it on the authorization request, and compares it when the provider redirects back.
Without that check, an attacker can complete a login flow in their own browser, capture the resulting callback URL with a valid code, and induce a victim to visit it — silently logging the victim into the attacker’s account. Everything the victim then does happens in an account the attacker controls, which is a subtle and genuinely damaging attack.
So a mismatch has exactly two explanations: the stored value was lost, or the returned value was not yours. Distinguishing them is the whole job.
The Usual Cause: The Stored Value Was Lost
The SameSite case is the most common and the most confusing, because Strict sounds safer. A Strict cookie is not sent on a navigation that originates from another site — which is precisely what the provider’s redirect is — so the temporary state cookie is absent on exactly the request that needs it. Use SameSite=Lax for the short-lived login state, with HttpOnly, Secure and a lifetime of a few minutes.
Distinguish “no stored value” from “values differ” in your code and your logs. They have different causes and different responses, and collapsing them into one error is why these incidents take longer to diagnose than they should.
export async function handleCallback(req: Request) {
const expected = req.cookies["__Host-login-state"];
const received = String(req.query.state ?? "");
if (!expected) throw new LoginRestartRequired("state cookie absent"); // storage problem
if (!timingSafeEqual(expected, received)) throw new PossibleAttack("state mismatch"); // real signal
// …exchange the code, then clear the temporary cookie…
}
The Other Cause: Parallel Logins
A user with two tabs open, each starting a login, overwrites a single stored value — so the first callback to arrive validates and the second fails, apparently at random. The same happens when a user clicks a login link twice, or when a page prefetches the login route.
Store the state keyed by itself rather than as one value per session: a short-lived entry whose key is the state and whose value carries the verifier, the nonce and the return path. The callback looks up the entry by the state it received, which means several flows can be in progress at once and each resolves correctly. Delete the entry on use so a replay of the same callback fails.
Cap the number of concurrent entries per session and give them a few minutes of life. Both bound the storage a bored or malicious client can consume by repeatedly starting logins it never finishes.
Telling a Real Attack From a Broken Flow
Log the shape of each failure — absent versus differing — with the source address, the referrer and the user agent. A spike in the first, correlated with a deploy, is your own change. A trickle of the second is worth alerting on, because a successful login injection is invisible afterwards: the victim simply appears to be using someone else’s account.
Never respond to a mismatch by proceeding anyway, and never make it recoverable by trusting the returned value. The only safe response is to discard the callback and restart the flow from your own login endpoint.
Storing the Flow State Properly
Most of the failures above come from storing the temporary login state in the wrong place, so it is worth being explicit about what a correct store looks like.
The state entry belongs on the server, keyed by the random value itself, holding the PKCE verifier, the nonce, the intended return path and a creation timestamp. The browser holds nothing but a short-lived cookie carrying that key — HttpOnly, Secure, SameSite=Lax, and expiring in minutes. That arrangement survives a full navigation to another origin and back, works across several parallel logins, and keeps the verifier out of reach of any script on the page.
Deployments with several instances need the store to be shared, which is usually the same store that holds sessions. An in-memory map works perfectly in development and fails intermittently in production the moment a callback lands on a different instance from the one that started the flow — an especially frustrating failure because it appears random and depends on load-balancer behaviour.
Delete the entry as soon as it is consumed. A callback that arrives twice — a double-click, a retry, a prefetch — should fail the second time rather than starting a second exchange, and the deletion is what makes that automatic. It also caps how long an abandoned flow occupies storage.
Validating the Return Path
The return path stored alongside the state deserves its own scrutiny, because it is the one piece of that entry an attacker might influence. If your login accepts a ?next= parameter, that value ends up guiding a redirect after authentication, and an unvalidated redirect target turns your login flow into an open redirect wearing your domain’s reputation.
Validate it as a relative path against an allowlist of routes your application actually has, and reject anything with a scheme, a host, or a protocol-relative prefix. Storing it server-side with the rest of the flow state helps, because the value the user is redirected to is one you recorded rather than one that arrived on the callback — but the validation still has to happen when you record it.
Where a deep link genuinely needs to survive a login — a user following a shared URL into a protected page — store the path when the flow starts rather than passing it through the provider, and read it back from the flow state on the callback. The value then never leaves your server, which removes the parameter from the attack surface entirely rather than validating it repeatedly.
Log rejected return paths. Legitimate users produce almost none, so a stream of them is somebody testing what your login endpoint will accept, which is useful to know before they find something it does.
Frequently Asked Questions
Can I use the session identifier as the state?
No. The value travels through the provider and appears in redirect URLs, browser history and logs, so using anything session-identifying leaks it into places you do not control. Generate a fresh random value per flow, store it server-side against the pre-login session, and discard it on use.
Do I still need state if I use PKCE?
Yes — they solve different problems. PKCE stops someone who intercepts an authorization code from redeeming it. state stops someone from completing their own flow in your user’s browser. An implementation with PKCE and no state check is still vulnerable to login injection, which is the attack that quietly binds a victim’s activity to an attacker’s account.
Why does it work locally and fail in production?
Usually the temporary state cookie. In development everything is same-site on one origin, so even SameSite=Strict survives; in production the redirect arrives from the provider’s origin and a Strict cookie is withheld. Check the cookie’s SameSite and Secure attributes, and confirm the cookie is actually present on the callback request rather than assuming it.
Should the error be shown to the user?
Show a plain “we could not complete your sign-in, please try again” and restart the flow. Do not display the values or the reason: the detail helps nobody at the keyboard and would help someone probing. Put the diagnostic detail in your logs, where it belongs, with enough context to distinguish the two causes.
How long should the stored state live?
Five to ten minutes. Long enough for a user to authenticate, complete a second factor and be redirected back; short enough that abandoned flows expire rather than accumulating. Delete the entry when it is used, so a replayed callback fails on the second attempt as well as failing the code exchange.
Getting the storage and the return path right removes almost every occurrence of this error, leaving the small residue that genuinely deserves the alert it triggers.
Related
- Implementing authorization code flow with PKCE — where
state,nonceand the verifier fit together. - Debugging PKCE code verifier mismatches — the sibling failure with the same storage causes.
- Configuring secure cookie flags in production — why the temporary cookie needs
Laxrather thanStrict.