docs/content/docs/reference/errors/state_mismatch.mdx
When an OAuth or SSO flow begins, Better Auth generates a unique state value and stores it so it can be
verified when the provider redirects back. This prevents CSRF and replay attacks by ensuring the callback
truly belongs to the same browser session that started it.
Better Auth supports two state storage strategies - database (the default) and cookie - and each has its own failure modes. This page covers every state-related error code, why it fires, and how to fix it.
| Code | Message | Strategy | Meaning |
|---|---|---|---|
state_mismatch | verification not found | Database | The verification record for this state does not exist in the database (or secondary storage). |
state_mismatch | auth state cookie not found | Cookie | The encrypted state cookie was not sent back with the callback request. |
state_mismatch | request expired | Both | The state data was found but its expiresAt timestamp is in the past. |
state_invalid | Failed to decrypt or parse auth state | Cookie | The state cookie exists but cannot be decrypted or parsed (e.g. secret changed). |
state_security_mismatch | State not persisted correctly | Database | The signed state cookie is missing or does not match the state from the callback URL. |
state_generation_error | Unable to create verification | Database | The verification record could not be written when starting the flow. |
state_mismatch - verification not found (database strategy)This is the most commonly reported state error. It means the state value that came back from the OAuth provider was used to look up a verification record in the database, but no matching record was found.
The user took too long on the provider's login page. The verification record expires after 10 minutes.
Once expired, any other findVerificationValue call (from OTP checks, magic links, 2FA, etc.) triggers
a background cleanup that deletes all expired records - including this one.
The callback URL was loaded twice. After a successful lookup the verification record is immediately deleted. If the browser refreshes, the back button is pressed, or a redirect loop replays the callback, the second request finds nothing.
Secondary storage (Redis / KV) without database fallback. When secondaryStorage is configured,
verification records are stored there by default and the database is skipped. If the key is evicted
(TTL expiry, memory pressure, server restart) and verification.storeInDatabase is not explicitly true,
the lookup returns null without checking the database.
Multi-instance deployment without shared state. Serverless functions or multiple containers each running their own in-memory SQLite will not share verification records. The instance that created the record may not be the one that receives the callback.
Secret rotation with hashed identifiers. If verification.storeIdentifier is "hashed", the
identifier is hashed using the server secret. Changing BETTER_AUTH_SECRET between the start and
callback of the flow means the hash on lookup won't match the hash at rest.
The OAuth provider altered the state parameter. Some providers URL-encode, truncate, or otherwise
modify the state query parameter during the redirect, causing a mismatch on lookup.
Missing verification table. If database migrations were not run or the verification table was
dropped, the query returns nothing.
secondaryStorage, either set verification.storeInDatabase: true as a fallback, or ensure
your storage layer is reliable and the TTL is sufficient.BETTER_AUTH_SECRET during active OAuth flows, or run both old and new secrets during
a transition window."cookie" strategy which does not depend
on database lookups.state_mismatch - auth state cookie not found (cookie strategy)When storeStateStrategy is "cookie", all state data is encrypted into a cookie. This error means the
cookie was not present on the callback request.
.vercel.app preview domains are treated
as public suffixes and cannot share cookies across subdomains).Cookie header..vercel.app preview subdomains.SameSite / Secure attributes are correct for your deployment.state_mismatch - request expiredThe state data was successfully retrieved (from either the database or cookie), but its embedded
expiresAt timestamp has passed. The state payload is valid for 10 minutes from creation.
state_invalid - failed to decrypt or parse (cookie strategy)The encrypted state cookie exists but cannot be decrypted or the decrypted JSON cannot be parsed.
BETTER_AUTH_SECRET was rotated between the start and callback of the flow, so the decryption key
no longer matches.state_security_mismatch - state not persisted correctly (database strategy)After the verification record is found in the database, Better Auth also checks that a signed state cookie was sent back and its value matches the state from the callback URL. This is a second layer of CSRF protection. This error means the cookie is missing or its value does not match.
maxAge is 5 minutes, shorter than the 10-minute DB record expiry).SameSite policy prevented the cookie from being sent.SameSite=Lax cookies.skipStateCookieCheck internally.export const auth = betterAuth({
account: {
skipStateCookieCheck: true,
},
});
state_generation_error - unable to create verificationThis error is thrown at the start of the OAuth flow (not during the callback). It means the verification record could not be written to the database.
verification table does not exist - migrations have not been run.npx @better-auth/cli migrate to ensure all tables exist.databaseHooks on the verification model that might prevent writes.The table below ranks how frequently each root cause is seen in production, which error code it triggers, and what to do about it.
| Likelihood | Root cause | Error code | Fix |
|---|---|---|---|
| Very high | Cookie blocked or missing (Safari ITP, cross-domain, preview domains) | state_security_mismatch or state_mismatch (cookie strategy) | Use a stable custom domain; verify SameSite / Secure attributes |
| High | Callback URL replayed (refresh, back button, redirect loop) | state_mismatch (DB) | Ensure your error/redirect page does not re-trigger the callback |
| High | User took too long (>10 min) on the provider page | state_mismatch (DB) or request expired | Inform users to retry; consider switching to cookie strategy |
| High | Multiple tabs / concurrent sign-in attempts | state_security_mismatch | Only the last-opened tab's cookie is valid; earlier tabs will fail |
| Medium | Secondary storage (Redis) key evicted without DB fallback | state_mismatch (DB) | Set verification.storeInDatabase: true or ensure Redis persistence |
| Medium | Serverless / multi-instance without shared database | state_mismatch (DB) | Use a shared database or external storage accessible by all instances |
| Medium | Signed cookie expired (5 min) while DB record is still valid (10 min) | state_security_mismatch | The cookie has a shorter TTL than the DB record; users between 5–10 min will hit this |
| Low | Secret rotation mid-flow | state_invalid (cookie) or state_mismatch (DB + hashed) | Rotate secrets during low-traffic windows |
| Low | OAuth provider altered the state parameter | state_mismatch (DB) | Verify the provider preserves state exactly; check for URL encoding issues |
| Low | Missing verification table / migration not run | state_generation_error | Run npx @better-auth/cli migrate |
| Very low | Clock skew between server instances | request expired | Enable NTP on all servers |
error query parameter on your error page tells you the exact code
(state_mismatch, state_security_mismatch, state_invalid, or state_generation_error).better-auth.state or
better-auth.oauth_state) is set before the redirect and still present when the callback arrives.state query parameter is present and unmodified.details object in the error includes the state value - you can
cross-reference this with your verification table to see if the record exists, expired, or was
already deleted.