Skip to content

Commit f2132ae

Browse files
committed
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.
1 parent cfaef3f commit f2132ae

File tree

3 files changed

+90
-10
lines changed

3 files changed

+90
-10
lines changed

README.md

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -707,17 +707,17 @@ export default authkitMiddleware({
707707
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.
708708
709709
```ts
710-
import { NextResponse } from 'next/server'
711-
import { validateApiKey } from '@workos-inc/authkit-nextjs'
710+
import { NextResponse } from 'next/server';
711+
import { validateApiKey } from '@workos-inc/authkit-nextjs';
712712
713713
export async function GET() {
714-
const { apiKey } = await validateApiKey()
714+
const { apiKey } = await validateApiKey();
715715
716716
if (!apiKey) {
717-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
717+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
718718
}
719719
720-
return NextResponse.json({ success: true })
720+
return NextResponse.json({ success: true });
721721
}
722722
```
723723
@@ -785,6 +785,31 @@ await saveSession(session, req);
785785
await saveSession(session, 'https://example.com/callback');
786786
```
787787
788+
### CDN Deployments and Caching
789+
790+
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.
791+
792+
#### How It Works
793+
794+
The library automatically sets appropriate cache headers on all authenticated requests:
795+
796+
- `Cache-Control: private, no-cache, no-store, must-revalidate` - Prevents CDN caching of authenticated responses
797+
- `Vary: Cookie` - Ensures CDNs differentiate between different users
798+
- `Pragma: no-cache` - HTTP/1.0 compatibility
799+
800+
These headers are applied automatically when:
801+
802+
- A session cookie is present
803+
- An Authorization header is detected
804+
- Requests are made to auth routes (`/api/auth/*`, `/callback`)
805+
- Active authenticated sessions exist
806+
807+
#### Performance Considerations
808+
809+
**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.
810+
811+
**Public pages:** Unaffected by these security measures. Public routes without authentication context can still be cached normally.
812+
788813
### Debugging
789814
790815
To enable debug logs, initialize the middleware with the debug flag enabled.

src/authkit-callback-route.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import { saveSession } from './session.js';
55
import { errorResponseWithFallback, redirectWithFallback } from './utils.js';
66
import { getWorkOS } from './workos.js';
77

8+
/**
9+
* Prevents CDN caching of auth callback responses (critical for security)
10+
*/
11+
function preventCaching(headers: Headers): void {
12+
headers.set('Vary', 'Cookie');
13+
headers.set('Cache-Control', 'private, no-store');
14+
headers.set('Pragma', 'no-cache');
15+
headers.set('x-middleware-cache', 'no-cache');
16+
headers.set('CDN-Cache-Control', 'no-store');
17+
}
18+
819
function handleState(state: string | null) {
920
let returnPathname: string | undefined = undefined;
1021
let userState: string | undefined;
@@ -90,6 +101,7 @@ export function handleAuth(options: HandleAuthOptions = {}) {
90101
// Fall back to standard Response if NextResponse is not available.
91102
// This is to support Next.js 13.
92103
const response = redirectWithFallback(url.toString());
104+
preventCaching(response.headers);
93105

94106
if (!accessToken || !refreshToken) throw new Error('response is missing tokens');
95107

@@ -116,23 +128,28 @@ export function handleAuth(options: HandleAuthOptions = {}) {
116128

117129
console.error(errorRes);
118130

119-
return errorResponse(request, error);
131+
return await errorResponse(request, error);
120132
}
121133
}
122134

123-
return errorResponse(request);
135+
return await errorResponse(request);
124136
};
125137

126-
function errorResponse(request: NextRequest, error?: unknown) {
138+
async function errorResponse(request: NextRequest, error?: unknown) {
127139
if (onError) {
128-
return onError({ error, request });
140+
const response = await onError({ error, request });
141+
preventCaching(response.headers);
142+
return response;
129143
}
130144

131-
return errorResponseWithFallback({
145+
const response = errorResponseWithFallback({
132146
error: {
133147
message: 'Something went wrong',
134148
description: "Couldn't sign in. If you are not sure what happened, please contact your organization admin.",
135149
},
136150
});
151+
152+
preventCaching(response.headers);
153+
return response;
137154
}
138155
}

src/session.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@ const jwtCookieName = 'workos-access-token';
3030

3131
const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(WORKOS_CLIENT_ID))));
3232

33+
/**
34+
* Applies cache security headers to prevent CDN caching of authenticated responses.
35+
* Critical for preventing session crossover in CDN environments (CloudFront, Cloudflare, etc.)
36+
*/
37+
function applyCacheSecurityHeaders(headers: Headers, request: NextRequest): void {
38+
// Build Vary header with deduplication
39+
const varyValues = ['Cookie'];
40+
if (request.headers.has('authorization')) {
41+
varyValues.push('Authorization');
42+
}
43+
44+
const currentVary = headers.get('Vary');
45+
if (currentVary) {
46+
const existing = new Set(currentVary.split(',').map((v) => v.trim()));
47+
varyValues.forEach((v) => existing.add(v));
48+
headers.set('Vary', Array.from(existing).join(', '));
49+
} else {
50+
headers.set('Vary', varyValues.join(', '));
51+
}
52+
53+
// Prevent caching - critical for CDN security
54+
headers.set('Cache-Control', 'private, no-store');
55+
headers.set('Pragma', 'no-cache');
56+
headers.set('x-middleware-cache', 'no-cache');
57+
headers.set('CDN-Cache-Control', 'no-store');
58+
}
59+
3360
/**
3461
* Determines if a request is for an initial document load (not API/RSC/prefetch)
3562
*/
@@ -120,6 +147,17 @@ async function updateSessionMiddleware(
120147
headers.set(signUpPathsHeaderName, signUpPaths.join(','));
121148
}
122149

150+
// Apply cache security headers to prevent CDN caching of authenticated responses
151+
const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
152+
153+
if (
154+
session?.accessToken != null ||
155+
request.cookies.has(cookieName) ||
156+
request.headers.has('authorization')
157+
) {
158+
applyCacheSecurityHeaders(headers, request);
159+
}
160+
123161
return NextResponse.next({
124162
headers,
125163
});

0 commit comments

Comments
 (0)