fix(session): honor server-side session deletion in update-session and token routes (#9967)

This commit is contained in:
Gustavo Valverde
2026-06-10 00:42:45 +00:00
committed by GitHub
parent 5e49c56a9e
commit 893cf6cb3f
6 changed files with 196 additions and 2 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Deleting a session now immediately stops `/update-session` and the account token endpoints (`/get-access-token`, `/refresh-token`, `/account-info`) from accepting it, when cookie cache is enabled alongside a database or secondary storage. Before, these routes kept serving the deleted session from the cached cookie until the cache expired. Deployments that store the session only in the cookie are unaffected.
@@ -1865,3 +1865,48 @@ describe("account", async () => {
expect(refreshedAccountCookie).toBe(true);
});
});
describe("token routes cookie cache revocation", async () => {
it("get-access-token fails closed after the session is revoked in a stateful deployment", async () => {
const { auth, client, testUser, cookieSetter } = await getTestInstance({
session: { cookieCache: { enabled: true, maxAge: 60 } },
});
const headers = new Headers();
await client.signIn.email(
{ email: testUser.email, password: testUser.password },
{ onSuccess: cookieSetter(headers) },
);
const initial = await client.getSession({
fetchOptions: { headers, onSuccess: cookieSetter(headers) },
});
const sessionToken = initial.data!.session.token;
expect(headers.get("cookie")).toContain("session_data");
// Revoke server-side; the cookie cache is the only thing still vouching.
const ctx = await auth.$context;
await ctx.internalAdapter.deleteSession(sessionToken);
// resolveUserId validates against the database before any account lookup,
// so the revoked session is rejected outright rather than minting a token.
const res = await client.$fetch("/get-access-token", {
method: "POST",
body: { providerId: "google" },
headers,
});
expect(res.error?.status).toBe(401);
// A request must not re-enable the cookie cache to revive the revoked
// session. `z.coerce.boolean()` reads an empty value as false, so the
// forced strict validation has to ignore it.
const bypass = await client.$fetch(
"/get-access-token?disableCookieCache=",
{
method: "POST",
body: { providerId: "google" },
headers,
},
);
expect(bypass.error?.status).toBe(401);
});
});
+11 -1
View File
@@ -445,12 +445,22 @@ export const unlinkAccount = createAuthEndpoint(
* `userId` directly. Throws `UNAUTHORIZED` when an HTTP caller is
* unauthenticated, and `USER_ID_OR_SESSION_REQUIRED` when neither a session
* nor a `userId` is available.
*
* When a durable store is authoritative, bypasses the cookie cache: these
* routes mint or refresh provider access tokens, so a server-side session
* revocation must take effect immediately rather than waiting for the cached
* cookie to expire. DB-less deployments keep the session in the cookie itself,
* so the cache is left in place for them.
*/
async function resolveUserId(
ctx: GenericEndpointContext,
userId?: string,
): Promise<string> {
const session = await getSessionFromCtx(ctx);
const isStateful =
!!ctx.context.options.database || !!ctx.context.options.secondaryStorage;
const session = await getSessionFromCtx(ctx, {
disableCookieCache: isStateful,
});
if (!session && (ctx.request || ctx.headers)) {
throw ctx.error("UNAUTHORIZED");
}
@@ -2234,3 +2234,114 @@ describe("updateSession plugin authority fields", async () => {
});
});
});
describe("update-session cookie cache revocation", async () => {
it("fails closed when the backing session is revoked in a stateful deployment", async () => {
const { auth, client, testUser, cookieSetter } = await getTestInstance({
session: {
cookieCache: { enabled: true, maxAge: 60 },
additionalFields: {
theme: { type: "string", defaultValue: "light" },
},
},
});
const headers = new Headers();
await client.signIn.email(
{ email: testUser.email, password: testUser.password },
{ onSuccess: cookieSetter(headers) },
);
const initial = await client.getSession({
fetchOptions: { headers, onSuccess: cookieSetter(headers) },
});
const sessionToken = initial.data!.session.token;
expect(headers.get("cookie")).toContain("session_data");
// Revoke server-side; only the cookie cache still vouches for the session.
const ctx = await auth.$context;
await ctx.internalAdapter.deleteSession(sessionToken);
const update = await client.$fetch("/update-session", {
method: "POST",
body: { theme: "dark" },
headers,
});
expect(update.error?.status).toBe(401);
// The database is authoritative and says the session is gone.
const strict = await client.getSession({
query: { disableCookieCache: true },
fetchOptions: { headers },
});
expect(strict.data).toBeNull();
});
it("still refreshes the cookie in a DB-less deployment when no row exists", async () => {
const { auth, client, testUser, cookieSetter } = await getTestInstance({
database: undefined as any,
session: {
additionalFields: { theme: { type: "string", defaultValue: "light" } },
},
});
const headers = new Headers();
await client.signIn.email(
{ email: testUser.email, password: testUser.password },
{ onSuccess: cookieSetter(headers) },
);
const initial = await client.getSession({
fetchOptions: { headers, onSuccess: cookieSetter(headers) },
});
const sessionToken = initial.data!.session.token;
// Simulate an instance whose in-memory store never held this session: the
// cookie is the source of truth, so the update must still apply.
const ctx = await auth.$context;
await ctx.internalAdapter.deleteSession(sessionToken);
const update = await client.$fetch("/update-session", {
method: "POST",
body: { theme: "dark" },
headers,
onSuccess: cookieSetter(headers),
});
expect(update.error).toBeNull();
expect(
(update.data as { session?: { theme?: string } } | null)?.session?.theme,
).toBe("dark");
});
});
describe("forced strict session validation", async () => {
it("a request cannot re-enable the cookie cache on a route that forces it off", async () => {
const { auth, client, testUser, cookieSetter } = await getTestInstance({
session: { cookieCache: { enabled: true, maxAge: 60 } },
});
const headers = new Headers();
await client.signIn.email(
{ email: testUser.email, password: testUser.password },
{ onSuccess: cookieSetter(headers) },
);
const initial = await client.getSession({
fetchOptions: { headers, onSuccess: cookieSetter(headers) },
});
const sessionToken = initial.data!.session.token;
const ctx = await auth.$context;
await ctx.internalAdapter.deleteSession(sessionToken);
// `/change-password` forces strict validation through
// `sensitiveSessionMiddleware`. An empty `disableCookieCache` query coerces
// to false, which must not weaken that forced check back to the cache.
const res = await client.$fetch("/change-password?disableCookieCache=", {
method: "POST",
body: {
currentPassword: testUser.password,
newPassword: "new-password-1234",
},
headers,
});
expect(res.error?.status).toBe(401);
});
});
@@ -547,6 +547,14 @@ export const getSessionFromCtx = async <
query: {
...config,
...ctx.query,
// `disableCookieCache`/`disableRefresh` only ever make validation
// stricter, so OR the caller's intent with the request. A caller that
// forces strict validation must not be weakened by a request query
// param (e.g. `?disableCookieCache=`), which the plain merge would let
// override the forced value back to false.
disableCookieCache:
config?.disableCookieCache || ctx.query?.disableCookieCache,
disableRefresh: config?.disableRefresh || ctx.query?.disableRefresh,
},
}).catch(() => {
return null;
@@ -2,7 +2,7 @@ import type { BetterAuthOptions } from "@better-auth/core";
import { createAuthEndpoint } from "@better-auth/core/api";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import * as z from "zod";
import { setSessionCookie } from "../../cookies";
import { deleteSessionCookie, setSessionCookie } from "../../cookies";
import { parseSessionInput, parseSessionOutput } from "../../db/schema";
import type { AdditionalSessionFieldsInput } from "../../types";
import { sessionMiddleware } from "./session";
@@ -81,6 +81,21 @@ export const updateSession = <O extends BetterAuthOptions>() =>
},
);
// A cookie-cached session with no backing row in a durable store was
// revoked or expired server-side; fail closed instead of re-minting from
// stale data, which would extend a revoked session. DB-less deployments
// keep the session in the cookie and legitimately have no row to update.
const isStateful =
!!ctx.context.options.database ||
!!ctx.context.options.secondaryStorage;
if (!updatedSession && isStateful) {
deleteSessionCookie(ctx);
throw APIError.from(
"UNAUTHORIZED",
BASE_ERROR_CODES.FAILED_TO_GET_SESSION,
);
}
const newSession = updatedSession ?? {
...session.session,
...additionalFields,