diff --git a/.changeset/ten-sites-look.md b/.changeset/ten-sites-look.md
new file mode 100644
index 0000000000..1a7b5bf366
--- /dev/null
+++ b/.changeset/ten-sites-look.md
@@ -0,0 +1,5 @@
+---
+"better-auth": patch
+---
+
+fix: cookie cache fallback lookup
diff --git a/packages/better-auth/src/api/routes/cookie-cache-fallback.test.ts b/packages/better-auth/src/api/routes/cookie-cache-fallback.test.ts
new file mode 100644
index 0000000000..4e06d21a14
--- /dev/null
+++ b/packages/better-auth/src/api/routes/cookie-cache-fallback.test.ts
@@ -0,0 +1,402 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { parseCookies } from "../../cookies";
+import { getTestInstance } from "../../test-utils/test-instance";
+
+/**
+ * @see https://github.com/better-auth/better-auth/issues/9233
+ *
+ * When `crossSubDomainCookies` is enabled for the first time on a live deployment,
+ * the browser ends up holding two cookies with the same name but different scopes:
+ * - Old `session_data` with Domain=www.app.example.com (issued before the change)
+ * - New `session_data` with Domain=.example.com (issued after the change)
+ *
+ * Per RFC 6265, the older cookie is listed first in the Cookie header.
+ * When HMAC verification fails on the old `session_data` cookie, Better Auth
+ * should fall through to validate `session_token` against the database
+ * instead of immediately returning null.
+ */
+describe("cookieCache HMAC verification failure fallback", async () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+ it("should fall through to session_token DB validation when session_data HMAC fails", async () => {
+ const { client, testUser, cookieSetter } = await getTestInstance({
+ session: {
+ cookieCache: {
+ enabled: true,
+ strategy: "compact",
+ },
+ },
+ });
+
+ const headers = new Headers();
+
+ // Sign in to get valid session_token and session_data cookies
+ await client.signIn.email(
+ {
+ email: testUser.email,
+ password: testUser.password,
+ },
+ {
+ onSuccess: cookieSetter(headers),
+ },
+ );
+
+ // Verify initial session works
+ const initialSession = await client.getSession({
+ fetchOptions: { headers },
+ });
+ expect(initialSession.data).not.toBeNull();
+ expect(initialSession.data?.user.email).toBe(testUser.email);
+
+ // Now simulate the cross-subdomain migration scenario:
+ // The browser has TWO session_data cookies, and per RFC 6265, the older
+ // (stale) one comes first. We simulate this by replacing session_data
+ // with an invalid value while keeping session_token valid.
+ const cookieStr = headers.get("cookie") || "";
+ const cookies = parseCookies(cookieStr);
+
+ // Get the valid session_token
+ const sessionToken = cookies.get("better-auth.session_token");
+ expect(sessionToken).toBeDefined();
+
+ // Create a new headers object with:
+ // 1. An INVALID session_data cookie (simulates the old cookie with wrong signature)
+ // 2. The VALID session_token cookie
+ const tampered = new Headers();
+
+ // Set invalid session_data first (simulating the older cookie that comes first per RFC 6265)
+ // This is base64-encoded JSON with an invalid signature
+ const invalidSessionData = btoa(
+ JSON.stringify({
+ session: {
+ session: { token: "fake", userId: "fake" },
+ user: { id: "fake", email: "fake@example.com" },
+ updatedAt: Date.now(),
+ },
+ expiresAt: Date.now() + 1000 * 60 * 5,
+ signature: "invalid-signature-that-will-fail-hmac-verification",
+ }),
+ );
+ tampered.set(
+ "cookie",
+ `better-auth.session_data=${invalidSessionData}; better-auth.session_token=${sessionToken}`,
+ );
+
+ // Should return the valid session by falling through to DB validation
+ const session = await client.getSession({
+ fetchOptions: { headers: tampered },
+ });
+
+ expect(session.data).not.toBeNull();
+ expect(session.data?.user.email).toBe(testUser.email);
+ });
+
+ it("should still return null when both session_data and session_token are invalid", async () => {
+ const { client, testUser, cookieSetter } = await getTestInstance({
+ session: {
+ cookieCache: {
+ enabled: true,
+ strategy: "compact",
+ },
+ },
+ });
+
+ const headers = new Headers();
+
+ // Sign in first to ensure the user exists
+ await client.signIn.email(
+ {
+ email: testUser.email,
+ password: testUser.password,
+ },
+ {
+ onSuccess: cookieSetter(headers),
+ },
+ );
+
+ // Create headers with both invalid session_data and invalid session_token
+ const tampered = new Headers();
+ const invalidSessionData = btoa(
+ JSON.stringify({
+ session: {
+ session: { token: "fake", userId: "fake" },
+ user: { id: "fake", email: "fake@example.com" },
+ updatedAt: Date.now(),
+ },
+ expiresAt: Date.now() + 1000 * 60 * 5,
+ signature: "invalid-signature",
+ }),
+ );
+ tampered.set(
+ "cookie",
+ `better-auth.session_data=${invalidSessionData}; better-auth.session_token=invalid-token.invalid-signature`,
+ );
+
+ // Should return null since both cookies are invalid
+ const session = await client.getSession({
+ fetchOptions: { headers: tampered },
+ });
+
+ expect(session.data).toBeNull();
+ });
+
+ it("should work with JWT strategy when HMAC verification fails", async () => {
+ const { client, testUser, cookieSetter } = await getTestInstance({
+ session: {
+ cookieCache: {
+ enabled: true,
+ strategy: "jwt",
+ },
+ },
+ });
+
+ const headers = new Headers();
+
+ await client.signIn.email(
+ {
+ email: testUser.email,
+ password: testUser.password,
+ },
+ {
+ onSuccess: cookieSetter(headers),
+ },
+ );
+
+ const cookieStr = headers.get("cookie") || "";
+ const cookies = parseCookies(cookieStr);
+ const sessionToken = cookies.get("better-auth.session_token");
+
+ // Create tampered headers with invalid JWT but valid session_token
+ const tampered = new Headers();
+ tampered.set(
+ "cookie",
+ `better-auth.session_data=invalid.jwt.token; better-auth.session_token=${sessionToken}`,
+ );
+
+ // Should fall through to DB validation
+ const session = await client.getSession({
+ fetchOptions: { headers: tampered },
+ });
+
+ expect(session.data).not.toBeNull();
+ expect(session.data?.user.email).toBe(testUser.email);
+ });
+
+ it("should work with JWE strategy when decryption fails", async () => {
+ const { client, testUser, cookieSetter } = await getTestInstance({
+ session: {
+ cookieCache: {
+ enabled: true,
+ strategy: "jwe",
+ },
+ },
+ });
+
+ const headers = new Headers();
+
+ await client.signIn.email(
+ {
+ email: testUser.email,
+ password: testUser.password,
+ },
+ {
+ onSuccess: cookieSetter(headers),
+ },
+ );
+
+ const cookieStr = headers.get("cookie") || "";
+ const cookies = parseCookies(cookieStr);
+ const sessionToken = cookies.get("better-auth.session_token");
+
+ // Create tampered headers with invalid JWE but valid session_token
+ const tampered = new Headers();
+ tampered.set(
+ "cookie",
+ `better-auth.session_data=invalid.jwe.token.here.test; better-auth.session_token=${sessionToken}`,
+ );
+
+ // Should fall through to DB validation
+ const session = await client.getSession({
+ fetchOptions: { headers: tampered },
+ });
+
+ expect(session.data).not.toBeNull();
+ expect(session.data?.user.email).toBe(testUser.email);
+ });
+
+ /**
+ * @see https://github.com/better-auth/better-auth/issues/9233
+ *
+ * This test emulates the exact scenario from the issue:
+ *
+ * Scenario: User has a valid session, but browser sends a stale/expired
+ * session_data cookie (e.g., from cross-subdomain migration where old
+ * Domain=www.app.example.com cookie arrives before new Domain=.example.com cookie)
+ *
+ * Step 1: User signs in, gets valid session_token and session_data
+ * Step 2: Wait for cookieCache.maxAge to expire (session_data becomes stale)
+ * Step 3: Simulate browser sending expired session_data but valid session_token
+ *
+ * Without fix: When session_data is expired/invalid, returns null (silent logout)
+ * With fix: Falls through to session_token DB validation -> session restored
+ */
+ it("should handle cross-subdomain cookie migration without silent logout", async () => {
+ const { client, testUser, cookieSetter } = await getTestInstance({
+ baseURL: "https://www.app.example.com",
+ session: {
+ cookieCache: {
+ enabled: true,
+ maxAge: 10, // Short window as described in issue
+ },
+ },
+ advanced: {
+ crossSubDomainCookies: {
+ enabled: true,
+ domain: ".example.com",
+ },
+ },
+ });
+
+ const headers = new Headers();
+
+ // Step 1: User signs in - gets session_token and session_data
+ await client.signIn.email(
+ {
+ email: testUser.email,
+ password: testUser.password,
+ },
+ {
+ onSuccess: cookieSetter(headers),
+ },
+ );
+
+ // Extract the valid session_token
+ const cookieStr = headers.get("cookie") || "";
+ const cookies = parseCookies(cookieStr);
+ const sessionToken =
+ cookies.get("__Secure-better-auth.session_token") ||
+ cookies.get("better-auth.session_token");
+ expect(sessionToken).toBeDefined();
+
+ // Verify initial session works
+ const initialSession = await client.getSession({
+ fetchOptions: { headers },
+ });
+ expect(initialSession.data).not.toBeNull();
+
+ // Step 2 & 3: Simulate the cross-subdomain migration scenario
+ // Browser has OLD session_data (from Domain=www.app.example.com) that arrives first
+ // The old session_data has expired OR has invalid signature due to migration
+ const migrationHeaders = new Headers();
+
+ // Create a stale/expired session_data that simulates the old cookie
+ // Per RFC 6265, the older cookie (Domain=www.app.example.com) arrives FIRST
+ const staleSessionData = btoa(
+ JSON.stringify({
+ session: {
+ session: {
+ token: "old-session-token",
+ userId: "old-user-id",
+ expiresAt: new Date(Date.now() - 1000).toISOString(),
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ user: {
+ id: "old-user-id",
+ email: testUser.email,
+ name: testUser.name,
+ emailVerified: false,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ updatedAt: Date.now() - 60000, // 1 minute ago
+ },
+ expiresAt: Date.now() - 1000, // Already expired
+ signature: "stale-signature-from-old-domain-cookie",
+ }),
+ );
+
+ // Send the stale session_data first (as browser would per RFC 6265)
+ // followed by the valid session_token
+ migrationHeaders.set(
+ "cookie",
+ `__Secure-better-auth.session_data=${staleSessionData}; __Secure-better-auth.session_token=${sessionToken}`,
+ );
+
+ // Step 3: Simulate visibilitychange -> useSession() refetch
+ // The stale session_data cookie is read, HMAC verification fails
+ const session = await client.getSession({
+ fetchOptions: { headers: migrationHeaders },
+ });
+
+ // Without the fix: session.data would be null (silent logout)
+ // With the fix: Falls through to session_token DB validation
+ expect(session.data).not.toBeNull();
+ expect(session.data?.user.email).toBe(testUser.email);
+ });
+
+ /**
+ * Same scenario but with shorter timing - verifying the bug is timing-dependent
+ * on cookieCache.maxAge as described in the issue
+ */
+ it("should work during cookieCache window even with stale session_data", async () => {
+ const { client, testUser, cookieSetter } = await getTestInstance({
+ session: {
+ cookieCache: {
+ enabled: true,
+ maxAge: 60, // 60 seconds
+ },
+ },
+ });
+
+ const headers = new Headers();
+
+ await client.signIn.email(
+ {
+ email: testUser.email,
+ password: testUser.password,
+ },
+ {
+ onSuccess: cookieSetter(headers),
+ },
+ );
+
+ // Verify session works initially
+ const initialSession = await client.getSession({
+ fetchOptions: { headers },
+ });
+ expect(initialSession.data).not.toBeNull();
+
+ // Get the valid session_token
+ const cookieStr = headers.get("cookie") || "";
+ const cookies = parseCookies(cookieStr);
+ const sessionToken = cookies.get("better-auth.session_token");
+
+ // Simulate the migration scenario with stale session_data but valid session_token
+ const migrationHeaders = new Headers();
+ const staleSessionData = btoa(
+ JSON.stringify({
+ session: {
+ session: { token: "stale", userId: "stale" },
+ user: { id: "stale", email: "stale@example.com" },
+ updatedAt: Date.now() - 1000 * 60 * 60, // 1 hour ago
+ },
+ expiresAt: Date.now() - 1000, // Already expired
+ signature: "stale-signature-from-old-deployment",
+ }),
+ );
+ migrationHeaders.set(
+ "cookie",
+ `better-auth.session_data=${staleSessionData}; better-auth.session_token=${sessionToken}`,
+ );
+
+ // Even with expired/stale session_data, should fall through to DB validation
+ const session = await client.getSession({
+ fetchOptions: { headers: migrationHeaders },
+ });
+
+ expect(session.data).not.toBeNull();
+ expect(session.data?.user.email).toBe(testUser.email);
+ });
+});
diff --git a/packages/better-auth/src/api/routes/session-api.test.ts b/packages/better-auth/src/api/routes/session-api.test.ts
index ad52a3b732..2a7e9a80a3 100644
--- a/packages/better-auth/src/api/routes/session-api.test.ts
+++ b/packages/better-auth/src/api/routes/session-api.test.ts
@@ -801,7 +801,12 @@ describe("cookie cache with JWT strategy", async () => {
headers,
},
});
- expect(res.data).toBeNull();
+ // When session_data verification fails, fall through to session_token DB validation.
+ // This is secure because the tampered data is not used, the real session from DB is returned.
+ // This behavior also handles cross-subdomain cookie migrations correctly.
+ expect(res.data).not.toBeNull();
+ expect(res.data?.user.id).not.toBe("tampered-id");
+ expect(res.data?.user.email).toBe(testUser.email);
});
it("should have max age expiry", async () => {
diff --git a/packages/better-auth/src/api/routes/session.ts b/packages/better-auth/src/api/routes/session.ts
index 6aee373fd3..eaeeae1253 100644
--- a/packages/better-auth/src/api/routes/session.ts
+++ b/packages/better-auth/src/api/routes/session.ts
@@ -137,8 +137,10 @@ export const getSession =