fix(last-login-method): include domain when clearing cross-subdomain cookies (#9319)

This commit is contained in:
Maxwell
2026-06-13 11:52:33 +00:00
committed by GitHub
parent cfbb9a0524
commit 581f8271fb
5 changed files with 131 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
fix(last-login-method): include domain when clearing cross-subdomain cookies
@@ -303,7 +303,8 @@ import { lastLoginMethodClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
lastLoginMethodClient({
cookieName: "better-auth.last_used_login_method" // Default: "better-auth.last_used_login_method"
cookieName: "better-auth.last_used_login_method", // Default: "better-auth.last_used_login_method"
domain: ".example.com" // Required for cross-subdomain cookie clearing
})
]
})
@@ -315,6 +316,12 @@ export const authClient = createAuthClient({
* Must match the server-side `cookieName` configuration
* Default: `"better-auth.last_used_login_method"`
**domain**: `string`
* The domain to use when clearing the cookie
* Required when using `crossSubDomainCookies` so the client can properly expire the cookie set by the server
* Should match the `domain` value in your server's `crossSubDomainCookies` configuration
### Default Method Resolution
By default, the plugin tracks these authentication methods:
@@ -340,6 +347,23 @@ The plugin automatically inherits cookie settings from Better Auth's centralized
When you enable `crossSubDomainCookies` or `crossOriginCookies` in your Better Auth config, the plugin will automatically use the same domain, secure, and sameSite settings as your session cookies, ensuring consistent behavior across your application.
<Callout type="warn">
When using `crossSubDomainCookies`, you must pass the `domain` option to `lastLoginMethodClient()` so that `clearLastUsedLoginMethod()` can properly expire the cookie. Without it, browsers will silently ignore the clear request because the domain doesn't match.
</Callout>
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
import { lastLoginMethodClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
lastLoginMethodClient({
domain: ".example.com" // Must match server crossSubDomainCookies domain // [!code highlight]
})
]
})
```
## Advanced Examples
### Custom Provider Tracking
@@ -11,6 +11,16 @@ export interface LastLoginMethodClientConfig {
* @default "better-auth.last_used_login_method"
*/
cookieName?: string | undefined;
/**
* Domain for the cookie. Required when using cross-subdomain cookies
* so the client can properly clear the cookie set by the server.
*
* Should match the `domain` value in your server's
* `crossSubDomainCookies` configuration.
*
* @example ".example.com"
*/
domain?: string | undefined;
}
function getCookieValue(name: string): string | null {
@@ -46,7 +56,8 @@ export const lastLoginMethodClient = (
*/
clearLastUsedLoginMethod: (): void => {
if (typeof document !== "undefined") {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
const domainPart = config.domain ? ` domain=${config.domain};` : "";
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;${domainPart}`;
}
},
/**
@@ -703,6 +703,86 @@ describe("lastLoginMethod", async () => {
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/9276
*/
it("should clear cross-subdomain cookies by including the domain attribute", async () => {
const { client, testUser } = await getTestInstance(
{
baseURL: "https://auth.example.com",
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: "example.com",
},
},
plugins: [lastLoginMethod()],
},
{
clientOptions: {
plugins: [lastLoginMethodClient({ domain: "example.com" })],
},
},
);
// Step 1: Sign in to get the cookie set with domain attribute
let setCookieHeader = "";
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onResponse(context) {
setCookieHeader = context.response.headers.get("set-cookie") || "";
},
},
);
// Verify the server sets the cookie with Domain=example.com
expect(setCookieHeader).toContain(
"better-auth.last_used_login_method=email",
);
expect(setCookieHeader).toContain("Domain=example.com");
// Step 2: Simulate clearing the cookie on the client side.
// Mock document.cookie to capture the string the client writes.
let writtenCookie = "";
const originalDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"document",
);
Object.defineProperty(globalThis, "document", {
value: {},
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.document, "cookie", {
get() {
return "better-auth.last_used_login_method=email";
},
set(val: string) {
writtenCookie = val;
},
configurable: true,
});
client.clearLastUsedLoginMethod();
// Restore
if (originalDescriptor) {
Object.defineProperty(globalThis, "document", originalDescriptor);
} else {
globalThis.document = undefined as any;
}
// The written cookie MUST include the domain to properly expire
// a cross-subdomain cookie.
expect(writtenCookie).toContain("domain=example.com");
});
it("should handle multiple set-cookie headers correctly", async () => {
// Create a custom plugin that sets an additional cookie to simulate multiple Set-Cookie headers
const multiCookiePlugin = {
+9 -9
View File
@@ -16977,14 +16977,14 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.0
'@types/node': 25.6.0
jest-mock: 29.7.0
'@jest/fake-timers@29.7.0':
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
'@types/node': 25.5.0
'@types/node': 25.6.0
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -17018,7 +17018,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -20244,7 +20244,7 @@ snapshots:
'@types/readable-stream@4.0.23':
dependencies:
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/resolve@1.20.2': {}
@@ -20256,7 +20256,7 @@ snapshots:
'@types/send@1.2.1':
dependencies:
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/serve-static@2.2.0':
dependencies:
@@ -20289,7 +20289,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/yargs-parser@21.0.3': {}
@@ -23319,7 +23319,7 @@ snapshots:
happy-dom@20.8.9:
dependencies:
'@types/node': 25.5.0
'@types/node': 25.6.0
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
@@ -23903,7 +23903,7 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.5.0
'@types/node': 25.6.0
jest-util: 29.7.0
jest-regex-util@29.6.3: {}
@@ -23911,7 +23911,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.5.0
'@types/node': 25.6.0
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11