fix(multi-session): prevent duplicate cookies when same user signs in multiple times (#7256)

Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
Paola Estefanía de Campos
2026-01-28 17:06:03 -08:00
committed by Alex Yang
co-authored by Alex Yang
parent 94b7589162
commit 9db76abbea
2 changed files with 93 additions and 14 deletions
@@ -305,26 +305,45 @@ export const multiSession = (options?: MultiSessionConfig | undefined) => {
handler: createAuthMiddleware(async (ctx) => {
const cookieString = ctx.context.responseHeaders?.get("set-cookie");
if (!cookieString) return;
const setCookies = parseSetCookieHeader(cookieString);
const newSession = ctx.context.newSession;
if (!newSession) return;
const sessionCookieConfig = ctx.context.authCookies.sessionToken;
const sessionToken = ctx.context.newSession?.session.token;
if (!sessionToken) return;
const sessionToken = newSession.session.token;
const cookieName = `${sessionCookieConfig.name}_multi-${sessionToken.toLowerCase()}`;
const setCookies = parseSetCookieHeader(cookieString);
const cookies = parseCookies(ctx.headers?.get("cookie") || "");
const cookieName = `${
sessionCookieConfig.name
}_multi-${sessionToken.toLowerCase()}`;
if (setCookies.get(cookieName) || cookies.get(cookieName)) return;
const currentMultiSessions =
Object.keys(Object.fromEntries(cookies)).filter(
isMultiSessionCookie,
).length + (cookieString.includes("session_token") ? 1 : 0);
const multiSessionKeys = Object.keys(
Object.fromEntries(cookies),
).filter(isMultiSessionCookie);
if (currentMultiSessions >= opts.maximumSessions) {
return;
const tokensToDelete: string[] = [];
for (const key of multiSessionKeys) {
const token = await ctx.getSignedCookie(key, ctx.context.secret);
if (!token) continue;
const session =
await ctx.context.internalAdapter.findSession(token);
if (session?.user.id === newSession.user.id) {
tokensToDelete.push(token);
ctx.setCookie(key, "", {
...sessionCookieConfig.attributes,
maxAge: 0,
});
}
}
if (tokensToDelete.length > 0) {
await ctx.context.internalAdapter.deleteSessions(tokensToDelete);
}
const currentCount =
multiSessionKeys.length -
tokensToDelete.length +
(cookieString.includes(sessionCookieConfig.name) ? 1 : 0);
if (currentCount > opts.maximumSessions) return;
await ctx.setSignedCookie(
cookieName,
@@ -149,6 +149,66 @@ describe("multi-session", async () => {
expect(res2.data).toHaveLength(0);
});
it("should replace old multi-session cookie when same user signs in again", async () => {
const sameUserHeaders = new Headers();
const sameUser = {
email: "same-user@test.com",
password: "password",
name: "Same User",
};
let firstSessionToken = "";
await client.signUp.email(sameUser, {
onSuccess: cookieSetter(sameUserHeaders),
onResponse(context) {
const header = context.response.headers.get("set-cookie");
const cookies = parseSetCookieHeader(header || "");
firstSessionToken =
cookies.get("better-auth.session_token")?.value.split(".")[0] || "";
},
});
const sessionsAfterFirst = await client.multiSession.listDeviceSessions({
fetchOptions: { headers: sameUserHeaders },
});
const firstUserSessions = sessionsAfterFirst.data?.filter(
(s) => s.user.email === sameUser.email,
);
expect(firstUserSessions).toHaveLength(1);
let secondSessionToken = "";
await client.signIn.email(
{
email: sameUser.email,
password: sameUser.password,
},
{
onSuccess: cookieSetter(sameUserHeaders),
onResponse(context) {
const header = context.response.headers.get("set-cookie");
const cookies = parseSetCookieHeader(header || "");
secondSessionToken =
cookies.get("better-auth.session_token")?.value.split(".")[0] || "";
// Verify old cookie is being deleted
const oldCookieName = `better-auth.session_token_multi-${firstSessionToken.toLowerCase()}`;
const oldCookie = cookies.get(oldCookieName);
expect(oldCookie?.["max-age"]).toBe(0);
},
headers: sameUserHeaders,
},
);
expect(secondSessionToken).not.toBe(firstSessionToken);
const sessionsAfterSecond = await client.multiSession.listDeviceSessions({
fetchOptions: { headers: sameUserHeaders },
});
const secondUserSessions = sessionsAfterSecond.data?.filter(
(s) => s.user.email === sameUser.email,
);
expect(secondUserSessions).toHaveLength(1);
expect(secondUserSessions?.[0]?.session.token).toBe(secondSessionToken);
});
it("should reject forged multi-session cookies on sign-out", async () => {
const attackerUser = {
email: "attacker@test.com",