Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
41 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
9564d85
fix: prevent __txn_* cookie accumulation via value-prefix encoding an…
Piyush-85 Jul 4, 2026
5037282
docs: prevent __txn_* cookie accumulation via value-prefix encoding a…
Piyush-85 Jul 4, 2026
5e68900
fix: remove prefetch flag, simplify txn cookie eviction to single-pha…
Piyush-85 Jul 10, 2026
d2a75cf
fix: lint fix
Piyush-85 Jul 10, 2026
2bcffd2
fix: fix txn cookie eviction limit and add session size warning
Piyush-85 Jul 13, 2026
591e8c2
fix: consolidate 431 docs and inline transaction cookie cleanup
Piyush-85 Jul 13, 2026
f964ab4
fix: extract eviction logic into evictOldestTransactionCookies privat…
Piyush-85 Jul 13, 2026
ef6b6af
fix: addressing coderabbit review comments
Piyush-85 Jul 17, 2026
5985220
fix: address code review findings on txn cookie accumulation PR
Piyush-85 Jul 27, 2026
95baeee
fix: failing test with passwordless nonce
Piyush-85 Jul 28, 2026
69a4ad6
fix: detect Sec-Purpose prefetch;prerender in isNonNavigationalReques…
Piyush-85 Jul 29, 2026
1a5dfa7
fix: address txn-accumulation review findings
Piyush-85 Jul 29, 2026
7ef34be
docs: update 401 to 204 on prefetch restriction
Piyush-85 Jul 29, 2026
33aae38
fix: addressing review comments
Piyush-85 Aug 10, 2026
169026d
fix: improving test coverage on stateless session
Piyush-85 Aug 12, 2026
a8484c4
fix: addressing review comments
Piyush-85 Aug 20, 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
f34b491
fix: prevent session-cookie accumulation (stale __session__N chunks, …
Piyush-85 Aug 21, 2026
6b38a2e
Merge remote-tracking branch 'origin/main' into fix/txn-accumulation
Piyush-85 Aug 24, 2026
c5c51f7
fix: addressing review comments
Piyush-85 Aug 24, 2026
5c2bdc0
fix: clamp scope/audience, split eviction warn, document dedup diverg…
Piyush-85 Aug 25, 2026
3bcf846
Merge branch 'main' into fix/txn-accumulation
Piyush-85 Aug 25, 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
93 changes: 83 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).
Comment thread
nandan-bhat marked this conversation as resolved.
Outdated

#### 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,83 @@ 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

### 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#### 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.
Comment thread
nandan-bhat marked this conversation as resolved.
Outdated

```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.
Comment thread
nandan-bhat marked this conversation as resolved.
Outdated

## 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
24 changes: 19 additions & 5 deletions src/server/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import {
buildForwardedResponseHeaders,
transformTargetUrl
} from "../utils/proxy.js";
import { isNonNavigationalRequest } from "../utils/request.js";
import {
ensureDefaultScope,
getScopeForAudience
Expand Down Expand Up @@ -672,6 +673,9 @@ export class AuthClient {
const method = req.method;

if (method === "GET" && sanitizedPathname === this.routes.login) {
if (isNonNavigationalRequest(req)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primary fix for the 431: Next.js prefetches to /auth/login were silently minting a __txn_* cookie each time. We now short-circuit prefetch requests before handleLogin runs, so no cookie is written. Real navigations are unaffected — see isNonNavigationalRequest for why only prefetch-exclusive headers are matched.

return new NextResponse(null, { status: 401 });
}
return this.handleLogin(req);
} else if (method === "GET" && sanitizedPathname === this.routes.logout) {
return this.handleLogout(req);
Expand Down Expand Up @@ -947,8 +951,11 @@ export class AuthClient {
// Set response and save transaction
const res = NextResponse.redirect(authorizationUrl.toString());

// Save transaction state
await this.transactionStore.save(res.cookies, transactionState);
await this.transactionStore.save(
res.cookies,
transactionState,
req?.cookies
);

return res;
}
Expand Down Expand Up @@ -1598,7 +1605,6 @@ export class AuthClient {
await this.sessionStore.set(req.cookies, res.cookies, session, true);
addCacheControlHeadersForSession(res);

// Clean up the current transaction cookie after successful authentication
await this.transactionStore.delete(res.cookies, state);

return res;
Expand Down Expand Up @@ -4177,7 +4183,11 @@ export class AuthClient {
`${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}`
);

await this.transactionStore.save(res.cookies, transactionState);
await this.transactionStore.save(
res.cookies,
transactionState,
req?.cookies
);

return [null, res];
}
Expand Down Expand Up @@ -5819,7 +5829,11 @@ export class AuthClient {
"Pass the NextResponse cookies (App Router: next/headers cookies; Pages Router: res.cookies)."
);
}
await this.transactionStore.save(resCookies, magicLinkTransactionState);
await this.transactionStore.save(
resCookies,
magicLinkTransactionState,
req?.cookies
);
}
}

Expand Down
1 change: 0 additions & 1 deletion src/server/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,6 @@ export class Auth0Client {
fetch: options.customFetch,
mfaTokenTtl,
cspNonce: options.cspNonce,

discoveryCache,
provider: this.provider
});
Expand Down
8 changes: 4 additions & 4 deletions src/server/mfa-popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as oauth from "oauth4webapi";
import { describe, expect, it, vi } from "vitest";

import { getDefaultRoutes } from "../test/defaults.js";
import { generateSecret } from "../test/utils.js";
import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js";
import { RESPONSE_TYPES, SessionData } from "../types/index.js";
import { createAuthCompletePostMessageResponse } from "../utils/html-helpers.js";
import { AuthClient } from "./auth-client.js";
Expand Down Expand Up @@ -166,7 +166,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => {
const transactionCookie = response.cookies.get(`__txn_${state}`);
expect(transactionCookie).toBeDefined();
const { payload: txn } = (await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult;
expect(txn.challengeMode).toBe("popup");
Expand Down Expand Up @@ -200,7 +200,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => {
const state = authUrl.searchParams.get("state")!;
const transactionCookie = response.cookies.get(`__txn_${state}`);
const { payload: txn } = (await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult;
// When challengeMode is 'redirect' (default), it's not stored to minimize cookie size
Expand Down Expand Up @@ -260,7 +260,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => {
const state = authUrl.searchParams.get("state")!;
const transactionCookie = response.cookies.get(`__txn_${state}`);
const { payload: txn } = (await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult;

Expand Down
Loading
Loading