One day web login simply stopped working. Clicking "Continue with Google" sent the browser off to Google, Google sent it back, and then Better Auth refused the callback with:
state_security_mismatch: State not persisted correctlyMobile sign-in, using the same providers and the same auth instance, kept working. Nothing in our Google or Apple configuration had changed. The credentials were valid. The redirect URIs were correct. And yet the web callback failed every time, deterministically.
The bug was not in OAuth at all. It was in the order of the plugins we passed to Better Auth — and it only surfaced because we start the OAuth flow from a Next.js Server Action. This post walks through why that combination drops the OAuth state cookie, and why the fix was a single plugin placed in a single position.
TL;DR
- The OAuth
statevalue is stored in a cookie when the flow starts and re-checked when the provider redirects back. If that cookie is missing on return, Better Auth throwsstate_security_mismatch. - We kick off sign-in from a Next.js Server Action. Server Actions only send cookies that were written through
next/headerscookies()— a rawSet-Cookieheader from a library response is dropped. - Better Auth's
nextCookies()plugin (frombetter-auth/next-js) bridges thoseSet-Cookieheaders into the Next.js cookie store. Without it, thestatecookie never reached the browser. nextCookies()must be the last plugin in the array, because it runs as an after-hook over the final response — anything appended after it would set cookies the bridge never sees.- Mobile was unaffected: the Expo client calls the HTTP endpoints directly, so
Set-Cookieflows normally. - A testing side-quest:
better-auth/next-jsis pure ESM and broke ts-jest, so we added a CommonJS stub to keep 27 suites loading.
The symptom: state set on the way out, gone on the way back
OAuth's state parameter is a CSRF guard. When you begin an authorization request, the client generates a random state, stashes it somewhere local, and includes it in the URL sent to the provider. When the provider redirects back, it echoes the same state, and the client compares the returned value against the stored one. If they don't match — or the stored one is gone — the flow is rejected. Better Auth stores that value in a cookie (__Secure-better-auth.state) and validates it in the callback.
So state_security_mismatch: State not persisted correctly is precise: it is not saying the values differed, it is saying the cookie set at the start of the flow was not present at the end. Something between "start sign-in" and "browser navigates to Google" was swallowing the Set-Cookie.
That "something" was the boundary we start the flow across.
Server Actions don't forward raw Set-Cookie headers
Our web sign-in is a Server Action. Here is the whole entry point:
// apps/web/lib/auth/actions.ts (excerpt)
"use server";
export async function signInWithOAuth(provider: "google" | "apple", redirectPath?: string) {
const safeRedirect =
redirectPath?.startsWith("/") && !redirectPath.startsWith("//") ? redirectPath : "/diaries";
const callbackURL = `/api/auth/callback?redirect=${encodeURIComponent(safeRedirect)}`;
const result = await auth.api.signInSocial({
body: { provider, callbackURL },
headers: await headers(),
});
if (result?.url) {
redirect(result.url);
}
}auth.api.signInSocial() does two things: it produces the provider authorization URL we redirect to, and — importantly — it sets the state cookie on the Better Auth response object. In a plain HTTP route handler, that response is what the browser receives, so its Set-Cookie header is sent as-is and the cookie lands.
Inside a Server Action there is no such response to hand back. Next.js commits cookies to the eventual response only if they were written through the next/headers cookies() API. A Set-Cookie header sitting on some library's internal Response object is invisible to that mechanism — it is simply discarded. signInSocial() "set" the cookie, we redirect()ed to Google, and the cookie was never actually attached to anything the browser saw. On the way back, there was nothing to validate against.
This is why mobile never broke. The Expo client talks to the Better Auth HTTP endpoints directly; the response with its Set-Cookie header goes straight to the native client, which stores it. Only the web path, routed through a Server Action, lost the cookie.
The bridge: nextCookies(), and why it has to be last
Better Auth ships a plugin for exactly this Next.js quirk. nextCookies() from better-auth/next-js registers an after-hook that takes the Set-Cookie headers Better Auth generated and re-writes them through next/headers cookies() — the one path Next.js honours inside Server Actions. Adding it was the entire fix:
// apps/web/lib/auth/server.ts (excerpt)
plugins: [
expo(),
anonymous({
onLinkAccount: async ({ anonymousUser, newUser }) => {
await accountQueries.migrateAccountData(anonymousUser.user.id, newUser.user.id);
},
}),
nextCookies(),
],The load-bearing detail is the position. nextCookies() works by observing the final set of cookies on a response and mirroring them into Next's store. Any plugin listed after it could set a cookie the bridge has already run past — that cookie would silently fail to persist, reintroducing the exact class of bug we just fixed. So the rule is not "add nextCookies()", it is "add nextCookies() last".
We left a comment saying so, in the imperative, because the array is a tempting place to append:
// apps/web/lib/auth/server.ts (excerpt)
// `nextCookies` MUST stay last: it bridges Better Auth's Set-Cookie headers to
// Next.js `cookies()` so cookies set inside Server Actions are actually written
// to the browser. Without it, the OAuth `state` cookie set by `signInSocial`
// (called from the signInWithOAuth Server Action) is dropped, and the Google
// callback fails with `state_security_mismatch: State not persisted correctly`.That comment earned its keep almost immediately. When we later added the anonymous() plugin for guest accounts, it had to slot in before nextCookies() — which is exactly where it sits above. The invariant held because it was written down at the point of temptation.
Two callbacks, one word: keep them separate
There is a naming trap in this flow worth calling out, because it looks like it should be part of the bug and isn't. There are two different "callbacks" here:
- The OAuth code-exchange callback,
/api/auth/callback/[provider], handled by Better Auth's[...all]catch-all route. This is where the provider'scodeis traded for a session, and where thestatecookie is actually validated. - Our post-login landing handler,
/api/auth/callback, which is what we pass ascallbackURLtosignInSocial().
We deliberately route through our own handler after Better Auth finishes, so a single place runs the "does this user need to pick a slug / finish account setup?" gate regardless of provider:
// apps/web/app/api/auth/callback/route.ts (excerpt)
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return NextResponse.redirect(`${origin}/login?error=...`);
}
const profile = await profileQueries.getPublicProfile(session.user.id);
if (!profile) { /* redirect to login with error */ }
if (!profile.slug) {
return NextResponse.redirect(`${origin}/account-name-setup`);
}The cookie bug lived entirely in step 1's world — the state cookie is set and checked inside Better Auth's own endpoints. Our landing handler runs later, once the session cookie is already present. Keeping the two callbacks conceptually separate is what made the failure legible: the error came from state validation, so the fix belonged with cookie plumbing, not with our redirect logic.
The regression guard, and an ESM detour
How do you keep a plugin-ordering bug from coming back? You cannot easily unit-test "the state cookie survives a real Google round-trip" — Better Auth itself is mocked in our suite. The durable guards we landed on are two:
- The imperative comment above, at the one line most likely to be edited carelessly.
- The plugin array reads top-to-bottom as an ordering contract, with
nextCookies()visibly pinned at the bottom.
Adding the plugin also exposed an unrelated but annoying fact: better-auth/next-js is distributed as pure ESM (.mjs), and our ts-jest setup runs in CommonJS, which cannot parse it. The moment we imported it in server.ts, the 27 test suites that transitively import the auth instance all failed to load. The fix mirrored how we already stub other Better Auth modules — a tiny CommonJS shim plus a moduleNameMapper entry:
// apps/web/tests/__stubs__/better-auth-next-js.js (excerpt)
module.exports = {
nextCookies: () => ({ id: "next-cookies" }),
};The stub returns a plugin-shaped no-op, because none of our tests exercise cookie bridging directly — they only need the module to import cleanly. It is a reminder that adding one runtime dependency can quietly move your whole test harness, and that the cheapest fix is often to match the pattern already in the repo rather than to reconfigure the transformer.
Takeaways
state_security_mismatchis a cookie-lifetime problem, not usually an OAuth-config one: thestatecookie was set but not persisted between request and callback.- Cookies set by a library inside a Next.js Server Action are dropped unless they go through
next/headerscookies(). Better Auth'snextCookies()plugin is the bridge — and it must be the last plugin so it observes every cookie. - When a bug is web-only but mobile-fine, suspect the platform boundary the flow crosses, not the shared configuration.
- Write the "this must stay last" reasoning at the exact line it protects; the next person adding a plugin is the one who needs it.
For how this auth stack came to be — and why we could swap providers without rewriting every caller — see Migrating Authentication Without Rewriting Every Caller. For the server-side counterpart, where the safe default is deny, see Protected by Default: A Hono API That Fails Closed.