From d6b4d2e2826377e720e9cba64cf60aa6a76d2ba0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 2 Nov 2025 16:15:39 -0600 Subject: [PATCH 1/3] fix: prevent caching authenticated pages Without Vary: Cookie, CDNs can't tell User A and User B apart and might serve cached authenticated content to the wrong person. We now set proper cache headers (Vary: Cookie, Cache-Control: private, no-store, etc.) on any request with auth context. Only affects authenticated routes - public pages still cache normally. Vercel handles this fine, but CloudFront/SST/OpenNext needed the explicit headers. --- README.md | 35 ++++++++++++++++++++++++++++++----- src/authkit-callback-route.ts | 27 ++++++++++++++++++++++----- src/session.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3beb995..74c65bd 100644 --- a/README.md +++ b/README.md @@ -707,17 +707,17 @@ export default authkitMiddleware({ Use the `validateApiKey` function in your application's public API endpoints to parse a [Bearer Authentication](https://swagger.io/docs/specification/v3_0/authentication/bearer-authentication/) header and validate the [API key](https://workos.com/docs/authkit/api-keys) with WorkOS. ```ts -import { NextResponse } from 'next/server' -import { validateApiKey } from '@workos-inc/authkit-nextjs' +import { NextResponse } from 'next/server'; +import { validateApiKey } from '@workos-inc/authkit-nextjs'; export async function GET() { - const { apiKey } = await validateApiKey() + const { apiKey } = await validateApiKey(); if (!apiKey) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - return NextResponse.json({ success: true }) + return NextResponse.json({ success: true }); } ``` @@ -785,6 +785,31 @@ await saveSession(session, req); await saveSession(session, 'https://example.com/callback'); ``` +### CDN Deployments and Caching + +AuthKit automatically implements cache security measures to protect against session leakage in CDN environments. This is particularly important when deploying to AWS with SST/OpenNext, Cloudflare, or other CDN configurations. + +#### How It Works + +The library automatically sets appropriate cache headers on all authenticated requests: + +- `Cache-Control: private, no-cache, no-store, must-revalidate` - Prevents CDN caching of authenticated responses +- `Vary: Cookie` - Ensures CDNs differentiate between different users +- `Pragma: no-cache` - HTTP/1.0 compatibility + +These headers are applied automatically when: + +- A session cookie is present +- An Authorization header is detected +- Requests are made to auth routes (`/api/auth/*`, `/callback`) +- Active authenticated sessions exist + +#### Performance Considerations + +**Authenticated pages:** Will not be cached at the CDN level and will always hit your origin server. This is the correct and secure behavior for session-based authentication. + +**Public pages:** Unaffected by these security measures. Public routes without authentication context can still be cached normally. + ### Debugging To enable debug logs, initialize the middleware with the debug flag enabled. diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index e826a9f..f289807 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -5,6 +5,17 @@ import { saveSession } from './session.js'; import { errorResponseWithFallback, redirectWithFallback } from './utils.js'; import { getWorkOS } from './workos.js'; +/** + * Prevents CDN caching of auth callback responses (critical for security) + */ +function preventCaching(headers: Headers): void { + headers.set('Vary', 'Cookie'); + headers.set('Cache-Control', 'private, no-store'); + headers.set('Pragma', 'no-cache'); + headers.set('x-middleware-cache', 'no-cache'); + headers.set('CDN-Cache-Control', 'no-store'); +} + function handleState(state: string | null) { let returnPathname: string | undefined = undefined; let userState: string | undefined; @@ -90,6 +101,7 @@ export function handleAuth(options: HandleAuthOptions = {}) { // Fall back to standard Response if NextResponse is not available. // This is to support Next.js 13. const response = redirectWithFallback(url.toString()); + preventCaching(response.headers); if (!accessToken || !refreshToken) throw new Error('response is missing tokens'); @@ -116,23 +128,28 @@ export function handleAuth(options: HandleAuthOptions = {}) { console.error(errorRes); - return errorResponse(request, error); + return await errorResponse(request, error); } } - return errorResponse(request); + return await errorResponse(request); }; - function errorResponse(request: NextRequest, error?: unknown) { + async function errorResponse(request: NextRequest, error?: unknown) { if (onError) { - return onError({ error, request }); + const response = await onError({ error, request }); + preventCaching(response.headers); + return response; } - return errorResponseWithFallback({ + const response = errorResponseWithFallback({ error: { message: 'Something went wrong', description: "Couldn't sign in. If you are not sure what happened, please contact your organization admin.", }, }); + + preventCaching(response.headers); + return response; } } diff --git a/src/session.ts b/src/session.ts index e4c3fb2..691d408 100644 --- a/src/session.ts +++ b/src/session.ts @@ -30,6 +30,33 @@ const jwtCookieName = 'workos-access-token'; const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(WORKOS_CLIENT_ID)))); +/** + * Applies cache security headers to prevent CDN caching of authenticated responses. + * Critical for preventing session crossover in CDN environments (CloudFront, Cloudflare, etc.) + */ +function applyCacheSecurityHeaders(headers: Headers, request: NextRequest): void { + // Build Vary header with deduplication + const varyValues = ['Cookie']; + if (request.headers.has('authorization')) { + varyValues.push('Authorization'); + } + + const currentVary = headers.get('Vary'); + if (currentVary) { + const existing = new Set(currentVary.split(',').map((v) => v.trim())); + varyValues.forEach((v) => existing.add(v)); + headers.set('Vary', Array.from(existing).join(', ')); + } else { + headers.set('Vary', varyValues.join(', ')); + } + + // Prevent caching - critical for CDN security + headers.set('Cache-Control', 'private, no-store'); + headers.set('Pragma', 'no-cache'); + headers.set('x-middleware-cache', 'no-cache'); + headers.set('CDN-Cache-Control', 'no-store'); +} + /** * Determines if a request is for an initial document load (not API/RSC/prefetch) */ @@ -120,6 +147,13 @@ async function updateSessionMiddleware( headers.set(signUpPathsHeaderName, signUpPaths.join(',')); } + // Apply cache security headers to prevent CDN caching of authenticated responses + const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + + if (session?.accessToken != null || request.cookies.has(cookieName) || request.headers.has('authorization')) { + applyCacheSecurityHeaders(headers, request); + } + return NextResponse.next({ headers, }); From dbdcc4544aae552a7519c958ab76bf201e705d55 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 2 Nov 2025 16:50:53 -0600 Subject: [PATCH 2/3] readme updates --- README.md | 9 +++++---- src/authkit-callback-route.ts | 2 +- src/session.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 74c65bd..1e9b26b 100644 --- a/README.md +++ b/README.md @@ -793,16 +793,17 @@ AuthKit automatically implements cache security measures to protect against sess The library automatically sets appropriate cache headers on all authenticated requests: -- `Cache-Control: private, no-cache, no-store, must-revalidate` - Prevents CDN caching of authenticated responses +- `Cache-Control: private, no-store, must-revalidate` - Prevents CDN caching of authenticated responses - `Vary: Cookie` - Ensures CDNs differentiate between different users +- `CDN-Cache-Control: no-store` - Additional protection for CloudFront and Vercel +- `x-middleware-cache: no-cache` - Prevents middleware result caching (OpenNext/SST) - `Pragma: no-cache` - HTTP/1.0 compatibility These headers are applied automatically when: -- A session cookie is present +- A session cookie is present in the request - An Authorization header is detected -- Requests are made to auth routes (`/api/auth/*`, `/callback`) -- Active authenticated sessions exist +- An active authenticated session exists #### Performance Considerations diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index f289807..7b0132b 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -10,7 +10,7 @@ import { getWorkOS } from './workos.js'; */ function preventCaching(headers: Headers): void { headers.set('Vary', 'Cookie'); - headers.set('Cache-Control', 'private, no-store'); + headers.set('Cache-Control', 'private, no-store, must-revalidate'); headers.set('Pragma', 'no-cache'); headers.set('x-middleware-cache', 'no-cache'); headers.set('CDN-Cache-Control', 'no-store'); diff --git a/src/session.ts b/src/session.ts index 691d408..dcd36d0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -51,7 +51,7 @@ function applyCacheSecurityHeaders(headers: Headers, request: NextRequest): void } // Prevent caching - critical for CDN security - headers.set('Cache-Control', 'private, no-store'); + headers.set('Cache-Control', 'private, no-store, must-revalidate'); headers.set('Pragma', 'no-cache'); headers.set('x-middleware-cache', 'no-cache'); headers.set('CDN-Cache-Control', 'no-store'); From 1c608ae66c305e844e244dae2e2402cfd6bbdec7 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 3 Nov 2025 14:26:12 -0600 Subject: [PATCH 3/3] remove CDN-Cache-Control header --- README.md | 5 ++--- src/authkit-callback-route.ts | 1 - src/session.ts | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1e9b26b..7fac854 100644 --- a/README.md +++ b/README.md @@ -794,9 +794,8 @@ AuthKit automatically implements cache security measures to protect against sess The library automatically sets appropriate cache headers on all authenticated requests: - `Cache-Control: private, no-store, must-revalidate` - Prevents CDN caching of authenticated responses -- `Vary: Cookie` - Ensures CDNs differentiate between different users -- `CDN-Cache-Control: no-store` - Additional protection for CloudFront and Vercel -- `x-middleware-cache: no-cache` - Prevents middleware result caching (OpenNext/SST) +- `Vary: Cookie` - Ensures CDNs differentiate between different users (defense-in-depth) +- `x-middleware-cache: no-cache` - Prevents Next.js middleware result caching - `Pragma: no-cache` - HTTP/1.0 compatibility These headers are applied automatically when: diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index 7b0132b..195a31f 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -13,7 +13,6 @@ function preventCaching(headers: Headers): void { headers.set('Cache-Control', 'private, no-store, must-revalidate'); headers.set('Pragma', 'no-cache'); headers.set('x-middleware-cache', 'no-cache'); - headers.set('CDN-Cache-Control', 'no-store'); } function handleState(state: string | null) { diff --git a/src/session.ts b/src/session.ts index dcd36d0..693f701 100644 --- a/src/session.ts +++ b/src/session.ts @@ -54,7 +54,6 @@ function applyCacheSecurityHeaders(headers: Headers, request: NextRequest): void headers.set('Cache-Control', 'private, no-store, must-revalidate'); headers.set('Pragma', 'no-cache'); headers.set('x-middleware-cache', 'no-cache'); - headers.set('CDN-Cache-Control', 'no-store'); } /**