Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
103 changes: 93 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 @@ -242,6 +243,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]
> A default `<Link href="/auth/login">` is safe — the SDK returns `204 No Content` on AUTO prefetches without writing a transaction cookie. Avoid `<Link href="/auth/login" prefetch={true}>` (FULL prefetch), which is indistinguishable from a real navigation server-side. 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 @@ -329,6 +333,9 @@ For example: `/auth/login?returnTo=/dashboard` would redirect the user to the `/
> [!NOTE]
> The URL specified as `returnTo` parameters must be registered in your client's **Allowed Callback URLs**.

> [!IMPORTANT]
> `returnTo`, `scope`, and `audience` query parameters on `/auth/login` are stored inside the encrypted transaction cookie. Any single field longer than 2 KB is silently clamped back to its default value and a one-time warning is logged, to keep the transaction cookie under the browser and proxy header limits (~4 KB per cookie). Keep these values short. For `returnTo` specifically, the fallback is `signInReturnToPath` (the SDK-configured default post-login path).

### Redirecting the user after logging out

The `returnTo` parameter can be appended to the logout to specify where you would like to redirect the user after they have logged out.
Expand Down Expand Up @@ -575,6 +582,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 for protected pages. When a prefetch follows the redirect to `/auth/login`, the SDK returns `204 No Content` (no transaction cookie written). A middleware redirect achieves the same result, but `withPageAuthRequired` keeps the auth logic co-located with the page. 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 @@ -617,6 +627,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`) keeps auth logic co-located with the page. Prefetches that follow the redirect to `/auth/login` are handled by the SDK's `204` guard, so no transaction cookie is written. 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 @@ -4131,20 +4144,90 @@ 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 `204 No Content` 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. Avoid `<Link href="/auth/login" prefetch={true}>`.**

A default `<Link href="/auth/login">` (AUTO prefetch) is safe — the SDK detects the prefetch header and returns `204 No Content` without writing a transaction cookie. However, `<Link prefetch={true}>` triggers a FULL prefetch which sends no detectable prefetch header, so the SDK cannot distinguish it from a real navigation and will start a login flow. Use a plain `<a>` tag or `<Link prefetch={false}>` if you need to be explicit:

```tsx
// ✅ Safe — default Link, AUTO prefetch is caught by the 204 guard
<Link href="/auth/login">Sign In</Link>

// ✅ Safe — plain anchor never prefetches
<a href="/auth/login">Sign In</a>

// ✅ Safe — prefetch explicitly disabled
<Link href="/auth/login" prefetch={false}>
Sign In
</Link>

// ❌ Avoid — FULL prefetch is indistinguishable from a real navigation server-side
<Link href="/auth/login" prefetch={true}>
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. When a prefetch follows that redirect to `/auth/login`, the SDK returns `204 No Content` — so `handleLogin` is never called and no `__txn_*` cookie is written. A middleware redirect to `/auth/login` behaves the same way: prefetches that follow it are also caught by the `204` guard. The preference for `withPageAuthRequired` is about keeping auth logic co-located with the page, not a difference in prefetch behaviour.

```tsx
// ✅ Preferred — auth logic co-located with the page; prefetches caught by the 204 guard
export default auth0.withPageAuthRequired(async function Page() {
return <div>Protected content</div>;
}, { returnTo: "/protected" });
```

```ts
// ✅ Also safe — prefetches following this redirect are caught by the 204 guard
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.
> A default `<Link href="/auth/login">` is safe — the SDK detects the AUTO prefetch header and returns `204 No Content` without writing a transaction cookie. Avoid `<Link href="/auth/login" prefetch={true}>` (FULL prefetch): it sends no detectable prefetch header, so the SDK cannot distinguish it from a real navigation and will start a login flow. Use a plain `<a>` tag or `<Link prefetch={false}>` if you need to be safe across all prefetch modes. 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 @@ -27,7 +27,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 @@ -1723,7 +1723,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2053,7 +2053,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2407,7 +2407,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2454,7 +2454,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2497,7 +2497,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2548,7 +2548,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2603,7 +2603,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2793,7 +2793,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie.value,
stripTransactionValuePrefix(transactionCookie.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -2957,7 +2957,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie.value,
stripTransactionValuePrefix(transactionCookie.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -3044,7 +3044,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 @@ -7593,7 +7596,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -7740,7 +7743,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down Expand Up @@ -8183,7 +8186,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
expect(
(
(await decrypt(
transactionCookie!.value,
stripTransactionValuePrefix(transactionCookie!.value),
secret
)) as jose.JWTDecryptResult
).payload
Expand Down
Loading
Loading