fix(2fa): server-side trust device expiration and configurable maxAge (#7644)

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 c6f8f3b270
commit 5d15bf4a0a
6 changed files with 571 additions and 38 deletions
@@ -9,7 +9,11 @@ import { createOTP } from "@better-auth/utils/otp";
import { APIError } from "better-call";
import * as z from "zod";
import { sessionMiddleware } from "../../api";
import { deleteSessionCookie, setSessionCookie } from "../../cookies";
import {
deleteSessionCookie,
expireCookie,
setSessionCookie,
} from "../../cookies";
import { symmetricEncrypt } from "../../crypto";
import { generateRandomString } from "../../crypto/random";
import { mergeSchema } from "../../db/schema";
@@ -51,6 +55,8 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
const opts = {
twoFactorTable: "twoFactor",
};
const trustDeviceMaxAge =
options?.trustDeviceMaxAge ?? TRUST_DEVICE_COOKIE_MAX_AGE;
const backupCodeOptions = {
storeBackupCodes: "encrypted",
...options?.backupCodeOptions,
@@ -280,6 +286,25 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
await ctx.context.internalAdapter.deleteSession(
ctx.context.session.session.token,
);
const disableTrustCookie = ctx.context.createAuthCookie(
TRUST_DEVICE_COOKIE_NAME,
{
maxAge: trustDeviceMaxAge,
},
);
const disableTrustValue = await ctx.getSignedCookie(
disableTrustCookie.name,
ctx.context.secret,
);
if (disableTrustValue) {
const [, trustId] = disableTrustValue.split("!");
if (trustId) {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
trustId,
);
}
expireCookie(ctx, disableTrustCookie);
}
return ctx.json({ status: true });
},
),
@@ -308,7 +333,7 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
const trustDeviceCookieAttrs = ctx.context.createAuthCookie(
TRUST_DEVICE_COOKIE_NAME,
{
maxAge: TRUST_DEVICE_COOKIE_MAX_AGE,
maxAge: trustDeviceMaxAge,
},
);
// Check for trust device cookie
@@ -318,36 +343,62 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
);
if (trustDeviceCookie) {
const [token, sessionToken] = trustDeviceCookie.split("!");
const expectedToken = await createHMAC(
"SHA-256",
"base64urlnopad",
).sign(ctx.context.secret, `${data.user.id}!${sessionToken}`);
// Checks if the token is signed correctly, not that its the current session token
if (token === expectedToken) {
// Trust device cookie is valid, refresh it and skip 2FA
const newTrustDeviceCookie = ctx.context.createAuthCookie(
TRUST_DEVICE_COOKIE_NAME,
{
maxAge: TRUST_DEVICE_COOKIE_MAX_AGE,
},
);
const newToken = await createHMAC(
const [token, trustIdentifier] = trustDeviceCookie.split("!");
if (token && trustIdentifier) {
const expectedToken = await createHMAC(
"SHA-256",
"base64urlnopad",
).sign(
ctx.context.secret,
`${data.user.id}!${data.session.token}`,
`${data.user.id}!${trustIdentifier}`,
);
await ctx.setSignedCookie(
newTrustDeviceCookie.name,
`${newToken}!${data.session.token}`,
ctx.context.secret,
trustDeviceCookieAttrs.attributes,
);
return;
if (token === expectedToken) {
// HMAC is valid; verify the server-side record
const verificationRecord =
await ctx.context.internalAdapter.findVerificationValue(
trustIdentifier,
);
if (
verificationRecord &&
verificationRecord.value === data.user.id &&
verificationRecord.expiresAt > new Date()
) {
await ctx.context.internalAdapter.deleteVerificationValue(
verificationRecord.id,
);
const newTrustIdentifier = `trust-device-${generateRandomString(32)}`;
const newToken = await createHMAC(
"SHA-256",
"base64urlnopad",
).sign(
ctx.context.secret,
`${data.user.id}!${newTrustIdentifier}`,
);
await ctx.context.internalAdapter.createVerificationValue({
value: data.user.id,
identifier: newTrustIdentifier,
expiresAt: new Date(
Date.now() + trustDeviceMaxAge * 1000,
),
});
const newTrustDeviceCookie = ctx.context.createAuthCookie(
TRUST_DEVICE_COOKIE_NAME,
{
maxAge: trustDeviceMaxAge,
},
);
await ctx.setSignedCookie(
newTrustDeviceCookie.name,
`${newToken}!${newTrustIdentifier}`,
ctx.context.secret,
trustDeviceCookieAttrs.attributes,
);
return;
}
}
}
expireCookie(ctx, trustDeviceCookieAttrs);
}
/**
@@ -437,7 +437,8 @@ describe("two factor", async () => {
});
expect(signInRes.data?.user).toBeDefined();
// Should still work with original headers
// Old trust device cookie should no longer work because the
// server-side record was rotated on sign-in
const signIn2Res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
@@ -445,9 +446,9 @@ describe("two factor", async () => {
headers: newHeaders,
},
});
expect(signIn2Res.data?.user).toBeDefined();
expect((signIn2Res.data as any)?.twoFactorRedirect).toBe(true);
// Should work with updated headers
// Should work with the refreshed (rotated) headers
const signIn3Res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
@@ -848,6 +849,468 @@ describe("view backup codes", async () => {
});
});
describe("trust device server-side validation", async () => {
let OTP = "";
const { auth, signInWithTestUser, testUser, db } = await getTestInstance({
secret: DEFAULT_SECRET,
plugins: [
twoFactor({
otpOptions: {
sendOTP({ otp }) {
OTP = otp;
},
},
skipVerificationOnEnable: true,
}),
],
});
let { headers } = await signInWithTestUser();
it("should force 2FA when server-side trust record is expired", async () => {
// Enable 2FA
const enableRes = await auth.api.enableTwoFactor({
body: { password: testUser.password },
headers,
asResponse: true,
});
headers = convertSetCookieToCookie(enableRes.headers);
// Sign in to trigger 2FA
const signInRes = await auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
asResponse: true,
});
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
// Send and verify OTP with trustDevice
await auth.api.sendTwoFactorOTP({
headers: twoFactorHeaders,
body: {},
});
const verifyRes = await auth.api.verifyTwoFactorOTP({
headers: twoFactorHeaders,
body: {
code: OTP,
trustDevice: true,
},
asResponse: true,
});
expect(verifyRes.status).toBe(200);
// Extract the trust device cookie
const parsed = parseSetCookieHeader(
verifyRes.headers.get("Set-Cookie") || "",
);
const trustDeviceCookieValue = parsed.get(
"better-auth.trust_device",
)?.value;
expect(trustDeviceCookieValue).toBeDefined();
// The cookie value is signed: "value.signature" where value is "token!trustIdentifier"
// Extract the unsigned value (everything before the last dot)
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
const [, trustIdentifier] = unsignedValue.split("!");
expect(trustIdentifier).toBeDefined();
const verificationRecord = await db.findOne<{
id: string;
identifier: string;
value: string;
expiresAt: Date;
}>({
model: "verification",
where: [{ field: "identifier", value: trustIdentifier! }],
});
expect(verificationRecord).toBeDefined();
// Manually expire the record by setting expiresAt to the past
await db.update({
model: "verification",
where: [{ field: "id", value: verificationRecord!.id }],
update: {
expiresAt: new Date(Date.now() - 1000), // 1 second ago
},
});
// Now sign in with the trust device cookie - should require 2FA again
const trustHeaders = new Headers();
trustHeaders.set(
"cookie",
`better-auth.trust_device=${trustDeviceCookieValue}`,
);
const signIn2Res = await auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
headers: trustHeaders,
asResponse: true,
});
const signIn2Json = (await signIn2Res.json()) as {
twoFactorRedirect?: boolean;
};
expect(signIn2Json.twoFactorRedirect).toBe(true);
// The expired trust cookie should have been cleared
const signIn2Parsed = parseSetCookieHeader(
signIn2Res.headers.get("Set-Cookie") || "",
);
const clearedTrustCookie = signIn2Parsed.get("better-auth.trust_device");
expect(clearedTrustCookie?.value).toBe("");
});
it("should preserve trust device after sign-out", async () => {
// Sign in to trigger 2FA
const signInRes = await auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
asResponse: true,
});
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
// Send and verify OTP with trustDevice
await auth.api.sendTwoFactorOTP({
headers: twoFactorHeaders,
body: {},
});
const verifyRes = await auth.api.verifyTwoFactorOTP({
headers: twoFactorHeaders,
body: {
code: OTP,
trustDevice: true,
},
asResponse: true,
});
expect(verifyRes.status).toBe(200);
const sessionHeaders = convertSetCookieToCookie(verifyRes.headers);
// Extract the trust device cookie
const parsed = parseSetCookieHeader(
verifyRes.headers.get("Set-Cookie") || "",
);
const trustDeviceCookieValue = parsed.get(
"better-auth.trust_device",
)?.value;
expect(trustDeviceCookieValue).toBeDefined();
// Extract trust identifier (cookie is signed: "value.signature")
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
const [, trustIdentifier] = unsignedValue.split("!");
expect(trustIdentifier).toBeDefined();
const verificationBefore = await db.findOne<{
id: string;
identifier: string;
value: string;
expiresAt: Date;
}>({
model: "verification",
where: [{ field: "identifier", value: trustIdentifier! }],
});
expect(verificationBefore).toBeDefined();
// Sign out
const signOutRes = await auth.api.signOut({
headers: sessionHeaders,
asResponse: true,
});
expect(signOutRes.status).toBe(200);
// Verify the trust device cookie was NOT cleared (trust survives sign-out)
const signOutParsed = parseSetCookieHeader(
signOutRes.headers.get("Set-Cookie") || "",
);
const trustCookieAfterSignOut = signOutParsed.get(
"better-auth.trust_device",
);
// Cookie should either not be set (unchanged) or still have its value
expect(trustCookieAfterSignOut?.value || "preserved").not.toBe("");
// Verify the DB record still exists
const verificationAfter = await db.findOne<{
id: string;
identifier: string;
value: string;
expiresAt: Date;
}>({
model: "verification",
where: [{ field: "identifier", value: trustIdentifier! }],
});
expect(verificationAfter).toBeDefined();
// Sign in again with the trust device cookie - should skip 2FA
const trustHeaders = new Headers();
trustHeaders.set(
"cookie",
`better-auth.trust_device=${trustDeviceCookieValue}`,
);
const signIn2Res = await auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
headers: trustHeaders,
asResponse: true,
});
const signIn2Json = (await signIn2Res.json()) as {
user?: { email: string };
};
// Should NOT require 2FA - trust device survived sign-out
expect(signIn2Json.user?.email).toBe(testUser.email);
});
it("should revoke trust device when disabling 2FA", async () => {
// Sign in to trigger 2FA
const signInRes = await auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
asResponse: true,
});
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
// Send and verify OTP with trustDevice
await auth.api.sendTwoFactorOTP({
headers: twoFactorHeaders,
body: {},
});
const verifyRes = await auth.api.verifyTwoFactorOTP({
headers: twoFactorHeaders,
body: {
code: OTP,
trustDevice: true,
},
asResponse: true,
});
expect(verifyRes.status).toBe(200);
const sessionHeaders = convertSetCookieToCookie(verifyRes.headers);
// Extract the trust device cookie
const parsed = parseSetCookieHeader(
verifyRes.headers.get("Set-Cookie") || "",
);
const trustDeviceCookieValue = parsed.get(
"better-auth.trust_device",
)?.value;
expect(trustDeviceCookieValue).toBeDefined();
// Extract trust identifier (cookie is signed: "value.signature")
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
const [, trustIdentifier] = unsignedValue.split("!");
expect(trustIdentifier).toBeDefined();
const verificationBefore = await db.findOne<{
id: string;
identifier: string;
value: string;
expiresAt: Date;
}>({
model: "verification",
where: [{ field: "identifier", value: trustIdentifier! }],
});
expect(verificationBefore).toBeDefined();
// Disable 2FA
const disableRes = await auth.api.disableTwoFactor({
headers: sessionHeaders,
body: {
password: testUser.password,
},
asResponse: true,
});
expect(disableRes.status).toBe(200);
// Verify the trust device cookie was cleared
const disableParsed = parseSetCookieHeader(
disableRes.headers.get("Set-Cookie") || "",
);
const clearedCookie = disableParsed.get("better-auth.trust_device");
expect(clearedCookie?.value).toBe("");
// Verify the DB record was deleted
const verificationAfter = await db.findOne<{
id: string;
identifier: string;
value: string;
expiresAt: Date;
}>({
model: "verification",
where: [{ field: "identifier", value: trustIdentifier! }],
});
expect(verificationAfter).toBeNull();
});
});
describe("trustDeviceMaxAge", async () => {
const customMaxAge = 7 * 24 * 60 * 60; // 7 days instead of default 30
let OTP = "";
const { auth, signInWithTestUser, testUser, db } = await getTestInstance({
secret: DEFAULT_SECRET,
plugins: [
twoFactor({
otpOptions: {
sendOTP({ otp }) {
OTP = otp;
},
},
skipVerificationOnEnable: true,
trustDeviceMaxAge: customMaxAge,
}),
],
});
let { headers } = await signInWithTestUser();
it("should use custom trustDeviceMaxAge for the trust device cookie", async () => {
// Enable 2FA
const enableRes = await auth.api.enableTwoFactor({
body: { password: testUser.password },
headers,
asResponse: true,
});
headers = convertSetCookieToCookie(enableRes.headers);
// Sign in to trigger 2FA
const signInRes = await auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
asResponse: true,
});
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
// Send and verify OTP with trustDevice
await auth.api.sendTwoFactorOTP({
headers: twoFactorHeaders,
body: {},
});
const verifyRes = await auth.api.verifyTwoFactorOTP({
headers: twoFactorHeaders,
body: {
code: OTP,
trustDevice: true,
},
asResponse: true,
});
expect(verifyRes.status).toBe(200);
// Verify the trust device cookie has the custom max-age
const parsed = parseSetCookieHeader(
verifyRes.headers.get("Set-Cookie") || "",
);
const trustDeviceCookie = parsed.get("better-auth.trust_device");
expect(trustDeviceCookie).toBeDefined();
expect(Number(trustDeviceCookie?.["max-age"])).toBe(customMaxAge);
// Also verify the DB record has the correct expiration
const trustDeviceCookieValue = trustDeviceCookie?.value;
// Extract trust identifier (cookie is signed: "value.signature")
const lastDotIndex = trustDeviceCookieValue!.lastIndexOf(".");
const unsignedValue = trustDeviceCookieValue!.substring(0, lastDotIndex);
const [, trustIdentifier] = unsignedValue.split("!");
expect(trustIdentifier).toBeDefined();
const verificationRecord = await db.findOne<{
id: string;
identifier: string;
value: string;
expiresAt: Date;
}>({
model: "verification",
where: [{ field: "identifier", value: trustIdentifier! }],
});
expect(verificationRecord).toBeDefined();
// Expiration should be approximately customMaxAge seconds from now
const expiresAt = new Date(verificationRecord!.expiresAt).getTime();
const expectedExpiry = Date.now() + customMaxAge * 1000;
// Allow 5 second tolerance for test execution time
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(5000);
});
it("should use default 30 days when trustDeviceMaxAge is not specified", async () => {
let defaultOTP = "";
const { auth: authDefault, signInWithTestUser: signInDefault } =
await getTestInstance({
secret: DEFAULT_SECRET,
plugins: [
twoFactor({
otpOptions: {
sendOTP({ otp }) {
defaultOTP = otp;
},
},
skipVerificationOnEnable: true,
}),
],
});
const { headers: defaultHeaders } = await signInDefault();
// Enable 2FA
await authDefault.api.enableTwoFactor({
body: { password: testUser.password },
headers: defaultHeaders,
asResponse: true,
});
// Sign in to trigger 2FA
const signInRes = await authDefault.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
asResponse: true,
});
const twoFactorHeaders = convertSetCookieToCookie(signInRes.headers);
// Send and verify OTP with trustDevice
await authDefault.api.sendTwoFactorOTP({
headers: twoFactorHeaders,
body: {},
});
const verifyRes = await authDefault.api.verifyTwoFactorOTP({
headers: twoFactorHeaders,
body: {
code: defaultOTP,
trustDevice: true,
},
asResponse: true,
});
expect(verifyRes.status).toBe(200);
// Verify the trust device cookie has the default max-age (30 days)
const parsed = parseSetCookieHeader(
verifyRes.headers.get("Set-Cookie") || "",
);
const trustDeviceCookie = parsed.get("better-auth.trust_device");
expect(trustDeviceCookie).toBeDefined();
// Default is 30 days = 30 * 24 * 60 * 60 = 2592000 seconds
expect(Number(trustDeviceCookie?.["max-age"])).toBe(30 * 24 * 60 * 60);
});
});
describe("twoFactorCookieMaxAge", async () => {
const customMaxAge = 15 * 60; // 15 minutes
const { auth, signInWithTestUser, testUser } = await getTestInstance({
@@ -39,6 +39,14 @@ export interface TwoFactorOptions {
* @default 600 (10 minutes)
*/
twoFactorCookieMaxAge?: number | undefined;
/**
* Maximum age (in seconds) for the trusted device cookie.
* When a user opts to trust a device, this controls how long
* the device stays trusted before requiring 2FA again.
*
* @default 2592000 (30 days)
*/
trustDeviceMaxAge?: number | undefined;
}
export interface UserWithTwoFactor extends User {
@@ -3,6 +3,7 @@ import { createHMAC } from "@better-auth/utils/hmac";
import { APIError } from "better-call";
import { getSessionFromCtx } from "../../api";
import { expireCookie, setSessionCookie } from "../../cookies";
import { generateRandomString } from "../../crypto/random";
import { parseUserOutput } from "../../db/schema";
import {
TRUST_DEVICE_COOKIE_MAX_AGE,
@@ -76,23 +77,33 @@ export async function verifyTwoFactor(ctx: GenericEndpointContext) {
// Always clear the two factor cookie after successful verification
expireCookie(ctx, twoFactorCookie);
if (ctx.body.trustDevice) {
const plugin = ctx.context.getPlugin("two-factor");
const trustDeviceMaxAge = plugin!.options!.trustDeviceMaxAge;
const maxAge = trustDeviceMaxAge ?? TRUST_DEVICE_COOKIE_MAX_AGE;
const trustDeviceCookie = ctx.context.createAuthCookie(
TRUST_DEVICE_COOKIE_NAME,
{
maxAge: TRUST_DEVICE_COOKIE_MAX_AGE,
maxAge,
},
);
/**
* create a token that will be used to
* verify the device
* Create a random identifier for the trust device record.
* Store it in the verification table with an expiration
* so the server can validate and revoke it.
*/
const trustIdentifier = `trust-device-${generateRandomString(32)}`;
const token = await createHMAC("SHA-256", "base64urlnopad").sign(
ctx.context.secret,
`${user.id}!${session.token}`,
`${user.id}!${trustIdentifier}`,
);
await ctx.context.internalAdapter.createVerificationValue({
value: user.id,
identifier: trustIdentifier,
expiresAt: new Date(Date.now() + maxAge * 1000),
});
await ctx.setSignedCookie(
trustDeviceCookie.name,
`${token}!${session.token}`,
`${token}!${trustIdentifier}`,
ctx.context.secret,
trustDeviceCookie.attributes,
);
+2 -2
View File
@@ -67,7 +67,7 @@ export async function generateGenericState(
const stateCookie = c.context.createAuthCookie(
settings?.cookieName ?? "oauth_state",
{
maxAge: 10 * 60 * 1000, // 10 minutes
maxAge: 10 * 60, // 10 minutes
},
);
@@ -84,7 +84,7 @@ export async function generateGenericState(
const stateCookie = c.context.createAuthCookie(
settings?.cookieName ?? "state",
{
maxAge: 5 * 60 * 1000, // 5 minutes
maxAge: 5 * 60, // 5 minutes
},
);
+2 -2
View File
@@ -16,7 +16,7 @@ export const expoAuthorizationProxy = createAuthEndpoint(
const { oauthState } = ctx.query;
if (oauthState) {
const oauthStateCookie = ctx.context.createAuthCookie("oauth_state", {
maxAge: 10 * 60 * 1000, // 10 minutes
maxAge: 10 * 60, // 10 minutes
});
ctx.setCookie(
oauthStateCookie.name,
@@ -35,7 +35,7 @@ export const expoAuthorizationProxy = createAuthEndpoint(
});
}
const stateCookie = ctx.context.createAuthCookie("state", {
maxAge: 5 * 60 * 1000, // 5 minutes
maxAge: 5 * 60, // 5 minutes
});
await ctx.setSignedCookie(
stateCookie.name,