Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
16ae32f
fix: prevent __txn_* cookie accumulation via value-prefix encoding an…
Piyush-85 Jul 4, 2026
e29c7e0
docs: prevent __txn_* cookie accumulation via value-prefix encoding a…
Piyush-85 Jul 4, 2026
b6a3296
fix: remove prefetch flag, simplify txn cookie eviction to single-pha…
Piyush-85 Jul 10, 2026
541a86b
fix: lint fix
Piyush-85 Jul 10, 2026
b30ec24
fix: fix txn cookie eviction limit and add session size warning
Piyush-85 Jul 13, 2026
4dd65a7
fix: consolidate 431 docs and inline transaction cookie cleanup
Piyush-85 Jul 13, 2026
537de26
fix: extract eviction logic into evictOldestTransactionCookies privat…
Piyush-85 Jul 13, 2026
27119c4
fix: addressing coderabbit review comments
Piyush-85 Jul 17, 2026
d1d12f0
fix: address code review findings on txn cookie accumulation PR
Piyush-85 Jul 27, 2026
e7a44b3
fix: failing test with passwordless nonce
Piyush-85 Jul 28, 2026
a6af9f6
Merge branch 'main' into fix/txn-accumulation
Piyush-85 Jul 28, 2026
7867c52
fix: detect Sec-Purpose prefetch;prerender in isNonNavigationalReques…
Piyush-85 Jul 29, 2026
4bc926b
fix: delete stale __session__N chunks deterministically to prevent re…
Piyush-85 Jul 29, 2026
e6193cf
fix: dedup MFA step-up access tokens to prevent session-cookie growth…
Piyush-85 Jul 29, 2026
28a8c23
key MFA step-up token dedup on audience & scope to preserve different…
Piyush-85 Jul 29, 2026
27d2f7f
fix: key MFA token dedup on requested scope and tighten session cooki…
Piyush-85 Jul 29, 2026
f97c52a
fix: addressing review comments
Piyush-85 Aug 12, 2026
d3dffb8
fix: merge fix/txn-accumulation into fix/session-cookie-accumulation
Piyush-85 Aug 20, 2026
dda2ea6
fix: address session-cookie accumulation review findings
Piyush-85 Aug 20, 2026
47f5732
fix: addressing few nit comments
Piyush-85 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 86 additions & 10 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
- [Customizing Transaction Cookie Expiration](#customizing-transaction-cookie-expiration)
- [Transaction Management Modes](#transaction-management-modes)
- [Transaction Cookie Options](#transaction-cookie-options)
- [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors)
- [Database sessions](#database-sessions)
- [Using Client-Initiated Backchannel Authentication](#using-client-initiated-backchannel-authentication)
- [Connected Accounts](#connected-accounts)
Expand Down Expand Up @@ -240,6 +241,9 @@ The second option is through the query parameters to the `/auth/login` endpoint
<a href="/auth/login?audience=urn:my-api">Login</a>
```

> [!NOTE]
> Link to your login route with a plain `<a>` tag (as shown above) or `<Link prefetch={false}>` β€” never `<Link href="/auth/login">`. A prefetched `<Link>` starts a login flow that never completes, accumulating transaction cookies. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors).

### Social Login

To skip the Universal Login page and send users directly to a social provider, pass the `connection` parameter with the Auth0 connection name:
Expand Down Expand Up @@ -573,6 +577,9 @@ export async function middleware(request: NextRequest) {

## Protecting a Server-Side Rendered (SSR) Page

> [!TIP]
> Prefer `withPageAuthRequired` (below) over redirecting to `/auth/login` from middleware. Its redirect happens inside the render and is not followed during a Next.js prefetch, so no transaction cookie is written for prefetched protected pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors).

#### Page Router

Requests to `/pages/profile` without a valid session cookie will be redirected to the login page.
Expand Down Expand Up @@ -615,6 +622,9 @@ export default auth0.withPageAuthRequired(

To protect a Client-Side Rendered (CSR) page, you can use the `withPageAuthRequired` higher-order function. Requests to `/profile` without a valid session cookie will be redirected to the login page.

> [!TIP]
> Using `withPageAuthRequired` (rather than a middleware redirect to `/auth/login`) also avoids transaction-cookie accumulation on prefetched pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors).

```tsx
// app/profile/page.tsx
"use client";
Expand Down Expand Up @@ -4074,20 +4084,86 @@ const authClient = new Auth0Client({

**Use Single Transaction Mode When:**

- You want to prevent cookie accumulation issues in applications with frequent login attempts
- You prefer simpler transaction management
- You want the simplest possible transaction management
- Users typically don't need multiple concurrent login flows
- You're experiencing cookie header size limits due to abandoned transaction cookies edge cases

> [!NOTE]
> In single transaction mode, starting a new login while one is already in progress overwrites the existing `__txn_` cookie rather than rejecting the new attempt. If a user has two tabs open and starts a login in both, only the most recently started login can complete; the other tab's callback will fail because its transaction state was overwritten. This is expected in single transaction mode β€” use the default parallel mode if concurrent logins across tabs need to succeed.

### Transaction Cookie Options

| Option | Type | Description |
| ---------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| cookieOptions.maxAge | `number` | The expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned transaction cookies will expire automatically. |
| cookieOptions.prefix | `string` | The prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`. In single mode, just `__txn_`. |
| cookieOptions.sameSite | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. |
| cookieOptions.secure | `boolean` | When `true`, the cookie will only be sent over HTTPS connections. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. |
| cookieOptions.path | `string` | Specifies the URL path for which the cookie is valid. Defaults to `"/"`. |
| Option | Type | Description |
| ----------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transactionCookie.maxAge` | `number` | Expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned cookies expire automatically. |
| `transactionCookie.prefix` | `string` | Prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`; in single mode, just `__txn_`. |
| `transactionCookie.sameSite` | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. |
| `transactionCookie.secure` | `boolean` | When `true`, the cookie is only sent over HTTPS. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. |
| `transactionCookie.path` | `string` | URL path for which the cookie is valid. Defaults to `"/"`. |

### Preventing "431 Request Header Fields Too Large" Errors

If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookies have grown beyond your server's header size limit.

**This is fixed in the current SDK version.** The SDK now:

1. Returns `401` on Next.js prefetch requests to `/auth/login` (detected via prefetch headers such as `next-router-prefetch`, `purpose`, `sec-purpose`, and `x-middleware-prefetch`), so no `__txn_*` cookie is written for a flow that will never complete.
2. Automatically evicts accumulated `__txn_*` cookies once their combined size reaches a fixed internal limit (3500 bytes, roughly six concurrent in-flight logins) β€” oldest-first (FIFO) by creation timestamp β€” before writing the new cookie. Only transaction cookies are measured and evicted; the session and other cookies are never touched. This limit is not configurable.

#### Recommended practices to avoid transaction cookie accumulation

Even with the automatic protections above, follow these two practices so login flows are only started by real user navigation:

**1. Do not use `<Link href="/auth/login">`. Use a plain `<a>` tag or `<Link prefetch={false}>`.**

Next.js prefetches `<Link>` targets on hover or when they scroll into view. A prefetch of `/auth/login` starts a login flow (writing a `__txn_*` cookie) that the user never completes, since the prefetched response is discarded. Prevent it by not prefetching the login route:

```tsx
// βœ… Do β€” a plain anchor never prefetches
<a href="/auth/login">Sign In</a>

// βœ… Do β€” Link with prefetch disabled
<Link href="/auth/login" prefetch={false}>
Sign In
</Link>

// ❌ Don't β€” this prefetches /auth/login and writes a __txn_* cookie on hover/scroll
<Link href="/auth/login">Sign In</Link>
```

**2. Prefer `withPageAuthRequired` over middleware redirects to protect pages.**

`withPageAuthRequired` redirects to the login route from inside the React Server Component render. Next.js does **not** follow that redirect during a prefetch, so `handleLogin` is never called and no `__txn_*` cookie is written for prefetched protected pages. A middleware redirect to `/auth/login`, by contrast, is followed on prefetch of a protected page while the user is logged out β€” each prefetch then writes a transaction cookie.

```tsx
// βœ… Preferred β€” redirect happens in RSC render, not followed on prefetch
export default auth0.withPageAuthRequired(async function Page() {
return <div>Protected content</div>;
}, { returnTo: "/protected" });
```

```ts
// ⚠️ Middleware redirect β€” followed on prefetch of a protected page while
// logged out, writing a __txn_* cookie for a flow that never completes.
export async function middleware(request: NextRequest) {
const session = await auth0.getSession(request);
if (!session) {
return NextResponse.redirect(new URL("/auth/login", request.nextUrl.origin));
}
return NextResponse.next();
}
```

If you are running an older SDK version without the automatic protections above, adding `prefetch={false}` to `<Link>` components pointing to your login route is the key fallback.

If accumulation persists after upgrading, shorten the transaction cookie lifetime so abandoned logins expire sooner:

```ts
export const auth0 = new Auth0Client({
transactionCookie: {
maxAge: 600, // shorten TTL to 10 minutes (default 3600)
},
});
```

## Database sessions

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export default async function Home() {
```

> [!IMPORTANT]
> You must use `<a>` tags instead of the `<Link>` component to ensure that the routing is not done client-side as that may result in some unexpected behavior.
> Link to the login route with a plain `<a>` tag or `<Link prefetch={false}>` β€” do not use `<Link href="/auth/login">`. A prefetched `<Link>` starts a login flow that never completes, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details.

## Customizing the client

Expand Down
31 changes: 17 additions & 14 deletions src/server/auth-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
TokenRevocationErrorCode
} from "../errors/index.js";
import { getDefaultRoutes } from "../test/defaults.js";
import { generateSecret } from "../test/utils.js";
import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js";
import {
AccessTokenSet,
RESPONSE_TYPES,
Expand Down Expand Up @@ -1674,7 +1674,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2004,7 +2004,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2358,7 +2358,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2405,7 +2405,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2448,7 +2448,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2499,7 +2499,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2554,7 +2554,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2744,7 +2744,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie.value,
stripTransactionValuePrefix(transactionCookie.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2908,7 +2908,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie.value,
stripTransactionValuePrefix(transactionCookie.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2995,7 +2995,10 @@ ca/T0LLtgmbMmxSv/MmzIg==
const state = transactionCookie.name.replace("__txn_", "");
expect(transactionCookie).toBeDefined();
expect(
(await decrypt(transactionCookie!.value, secret))!.payload
(await decrypt(
stripTransactionValuePrefix(transactionCookie!.value),
secret
))!.payload
).toEqual(
expect.objectContaining({
nonce: expect.any(String),
Expand Down Expand Up @@ -7544,7 +7547,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -7691,7 +7694,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -8134,7 +8137,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down
Loading