fix(oauth): keep the state cookie alive as long as the state it protects

The signed state cookie expired after 5 minutes while the verification row
and the state payload it validates lived for 10, so callbacks in that window
failed with state_security_mismatch. Derive the cookie Max-Age and the row
TTL from the payload expiry and expose the window as account.stateExpiresIn
(default 600s).
This commit is contained in:
Bereket Engida
2026-09-04 14:54:47 -07:00
parent 5fe5bc21d1
commit 10056eb3fd
11 changed files with 96 additions and 17 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"better-auth": patch
"@better-auth/core": patch
"@better-auth/sso": patch
---
Keep the OAuth state cookie alive as long as the state it protects, and make the window configurable via `account.stateExpiresIn`.
@@ -34,7 +34,8 @@ provider was used to look up a verification record in the database, but no match
### Common causes
1. **The user took too long on the provider's login page.** The verification record expires after 10 minutes.
1. **The user took too long on the provider's login page.** The verification record expires after 10 minutes
by default (`account.stateExpiresIn`).
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.
@@ -97,7 +98,7 @@ cookie was not present on the callback request.
## `state_mismatch` - request expired
The 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.
`expiresAt` timestamp has passed. The state payload is valid for 10 minutes from creation by default.
### Common causes
@@ -108,7 +109,7 @@ The state data was successfully retrieved (from either the database or cookie),
* Ensure **NTP is enabled** on all server instances so clocks stay synchronized.
* If your users regularly need more than 10 minutes (e.g. enterprise SSO with approval workflows),
this timeout is currently not configurable - consider opening a feature request.
raise `account.stateExpiresIn` (in seconds). The state cookie and verification record follow the same window.
***
+8 -1
View File
@@ -479,6 +479,7 @@ export const auth = betterAuth({
},
encryptOAuthTokens: true, // Encrypt OAuth tokens before storing them in the database
storeStateStrategy: "database", // Store OAuth state payload in verification storage
stateExpiresIn: 10 * 60, // How long (seconds) an OAuth flow may take before its state expires
storeAccountCookie: true, // Store provider account data after OAuth flow in an encrypted cookie (useful for database-less flows)
accountLinking: {
enabled: true,
@@ -522,6 +523,12 @@ Controls where OAuth state data is stored during the authentication flow.
Defaults to `"database"` when a database or `secondaryStorage` is configured, and to `"cookie"` only when neither is set (a fully stateless setup). With `secondaryStorage`, OAuth state is stored there automatically; you no longer need to set this manually.
### `stateExpiresIn`
How long, in seconds, an OAuth flow may take between the redirect to the provider and the callback. One value drives the state payload expiry, the verification record TTL (database strategy) and the state cookie `Max-Age`, so the cookie can never expire before the state it protects. Raise it for providers where users routinely spend a long time on the provider side (MFA prompts, enterprise approval flows).
Defaults to `600` (10 minutes).
### `storeAccountCookie`
Store provider account data after an OAuth flow in an encrypted cookie. This is useful for database-less flows where OAuth token material, such as access tokens, refresh tokens, ID tokens, scopes, and token expiry, must be available without an account table.
@@ -684,7 +691,7 @@ export const auth = betterAuth({
* `database`: Database configuration options
* `backgroundTasks`: Configure background task handling for deferred operations
* `skipTrailingSlashes`: Skip trailing slash validation in route matching. (default: `false`)
* OAuth state configuration options (`storeStateStrategy`, `skipStateCookieCheck`) are now part of the `account` option
* OAuth state configuration options (`storeStateStrategy`, `skipStateCookieCheck`, `stateExpiresIn`) are now part of the `account` option
<h3 id="advanced-database"><code>database</code></h3>
@@ -290,6 +290,7 @@ Most of the features of Better Auth will not work correctly.`,
options.account?.storeStateStrategy ||
(isStateful ? "database" : "cookie"),
skipStateCookieCheck: !!options.account?.skipStateCookieCheck,
stateExpiresIn: options.account?.stateExpiresIn ?? 10 * 60,
},
tables,
trustedOrigins,
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { parseSetCookieHeader } from "../cookies";
import { getTestInstance } from "../test-utils/test-instance";
/**
* @see https://github.com/better-auth/better-auth/issues/11012
*
* The signed state cookie proves ownership of the verification row it is
* checked against, so it must live at least as long as that row. Both derive
* from a single `account.stateExpiresIn` window.
*/
describe("oauth state expiry", () => {
async function startSignIn(options?: { stateExpiresIn?: number }) {
const { client, auth } = await getTestInstance({
account: options,
socialProviders: {
google: { clientId: "test", clientSecret: "test" },
},
});
let maxAge: number | undefined;
const res = await client.signIn.social({
provider: "google",
callbackURL: "/callback",
fetchOptions: {
onSuccess(context) {
maxAge = parseSetCookieHeader(
context.response.headers.get("set-cookie") || "",
).get("better-auth.state")?.["max-age"];
},
},
});
const state = new URL(res.data!.url!).searchParams.get("state")!;
// Measured after the response so it can only be shorter than the cookie's
// remaining lifetime, never longer.
const now = Date.now();
const row = await auth.$context.then((c) =>
c.internalAdapter.findVerificationValue(state),
);
return { maxAge: maxAge!, rowTTL: row!.expiresAt.getTime() - now };
}
it("keeps the state cookie alive at least as long as the verification row", async () => {
const { maxAge, rowTTL } = await startSignIn();
expect(rowTTL).toBeGreaterThan(9 * 60 * 1000);
expect(maxAge * 1000).toBeGreaterThanOrEqual(rowTTL);
});
it("honors account.stateExpiresIn for the cookie and the row", async () => {
const { maxAge, rowTTL } = await startSignIn({ stateExpiresIn: 30 * 60 });
expect(rowTTL).toBeGreaterThan(29 * 60 * 1000);
expect(maxAge * 1000).toBeGreaterThanOrEqual(rowTTL);
});
});
+1 -1
View File
@@ -63,7 +63,7 @@ export async function generateState(
// Assigned after the client-controlled `additionalData` spread so a client
// cannot smuggle a `serverContext` of its own through the request body.
serverContext,
expiresAt: Date.now() + 10 * 60 * 1000,
expiresAt: Date.now() + c.context.oauthConfig.stateExpiresIn * 1000,
requestSignUp: c.body?.requestSignUp,
idTokenNonce: options?.idTokenNonce,
};
@@ -233,7 +233,7 @@ const oauthPopupStart = createAuthEndpoint(
errorURL: c.query.errorCallbackURL,
newUserURL: c.query.newUserCallbackURL,
requestSignUp: c.query.requestSignUp === "true" ? true : undefined,
expiresAt: Date.now() + 10 * 60 * 1000,
expiresAt: Date.now() + c.context.oauthConfig.stateExpiresIn * 1000,
};
await setOAuthState(stateData);
const { state } = await generateGenericState(c, stateData);
+7 -10
View File
@@ -86,6 +86,10 @@ export async function generateGenericState(
) {
const state = generateRandomString(32);
const storeStateStrategy = c.context.oauthConfig.storeStateStrategy;
// The payload `expiresAt` is what `parseGenericState` enforces, so the cookie
// and the verification row are derived from it and cannot outlive or, worse,
// expire before it.
const maxAge = Math.ceil((stateData.expiresAt - Date.now()) / 1000);
// Cookie strategy:
//
@@ -100,9 +104,7 @@ export async function generateGenericState(
const stateCookie = c.context.createAuthCookie(
settings?.cookieName ?? "oauth_state",
{
maxAge: 10 * 60, // 10 minutes
},
{ maxAge },
);
c.setCookie(stateCookie.name, encryptedData, stateCookie.attributes);
@@ -119,9 +121,7 @@ export async function generateGenericState(
// the adapter hashes it at rest when storeIdentifier is set
const stateCookie = c.context.createAuthCookie(
settings?.cookieName ?? "state",
{
maxAge: 5 * 60, // 5 minutes
},
{ maxAge },
);
await c.setSignedCookie(
@@ -131,16 +131,13 @@ export async function generateGenericState(
stateCookie.attributes,
);
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + 10);
const verification = await c.context.internalAdapter.createVerificationValue({
value: JSON.stringify({
...stateData,
oauthState: state,
} satisfies StateData),
identifier: state,
expiresAt,
expiresAt: new Date(stateData.expiresAt),
});
if (!verification) {
+4
View File
@@ -383,6 +383,10 @@ export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> =
* @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
*/
storeStateStrategy: "database" | "cookie";
/**
* OAuth state lifetime in seconds. Resolved from `account.stateExpiresIn`.
*/
stateExpiresIn: number;
};
/**
* New session that will be set after the request
+9
View File
@@ -1313,6 +1313,15 @@ export type BetterAuthOptions = {
* @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
*/
storeStateStrategy?: "database" | "cookie";
/**
* How long an OAuth flow may take between the redirect to the provider
* and the callback, in seconds. Drives the state payload expiry, the
* verification record TTL and the state cookie `Max-Age` together so
* the cookie can never expire before the state it protects.
*
* @default 600 (10 minutes)
*/
stateExpiresIn?: number;
/**
* Store provider account data after an OAuth flow in an encrypted
* cookie. This includes OAuth token material such as access tokens,
+1 -1
View File
@@ -32,7 +32,7 @@ export async function generateRelayState(
/**
* This is the actual expiry time of the state
*/
expiresAt: Date.now() + 10 * 60 * 1000,
expiresAt: Date.now() + c.context.oauthConfig.stateExpiresIn * 1000,
requestSignUp: c.body.requestSignUp,
serverContext: providerReference
? { [SSO_PROVIDER_STATE_KEY]: providerReference }