How to Set Access-Control-Max-Age Effectively
Symptom you are seeing:
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check.
Or, the inverse problem: preflight OPTIONS requests appear on every single cross-origin call in the Network tab, even for repeated requests to the same endpoint, indicating the preflight result is not being cached at all.
The Access-Control-Max-Age response header tells the browser how many seconds it may cache the result of a preflight OPTIONS request before repeating it. When it is absent, misconfigured, or set to a value that exceeds a browser’s hard cap, the browser silently discards it — and every cross-origin request pays the extra round-trip. This page is part of Cache Duration Tuning & Max-Age, which covers Access-Control-Max-Age mechanics and browser-imposed cache limits.
How the browser preflight cache works
The WHATWG Fetch Standard (section 4.8, “HTTP-network-or-cache fetch”) defines a per-origin preflight cache. When the browser receives a valid OPTIONS response containing Access-Control-Max-Age, it stores the permission grant — the tuple of (origin, URL, method, headers) — for up to the stated number of seconds. Any subsequent request that matches the same tuple skips the OPTIONS round-trip entirely.
The key constraint: browsers impose their own hard ceiling on the value regardless of what the server sends.
Browser caps — the most important fact on this page
Values above the engine limit are silently clamped with no console warning. Setting maxAge: 3600 in your server config does nothing extra for Chrome users — they still see a preflight every 10 minutes.
| Browser engine | Hard cap | Behaviour above cap |
|---|---|---|
| Chrome / Edge (Blink) | 600 s (10 min) | Silently clamped to 600 |
| Safari (WebKit) | 600 s (10 min) | Silently clamped to 600 |
| Firefox (Gecko) | 86 400 s (24 h) | Silently clamped to 86 400 |
The practical cross-browser maximum is 600 seconds. Any value higher gives Firefox users a longer window but provides no benefit to Chrome or Safari users, who make up the majority of browser traffic.
Prerequisite state
Before setting Access-Control-Max-Age, verify that:
- The server already emits
Access-Control-Allow-Originwith the correct allowed origin (not a blanket wildcard on credentialed routes). - The
Access-Control-Allow-MethodsandAccess-Control-Allow-Headerslists match the actual methods and request headers your frontend sends. - Header deduplication is in place: no middleware layer is emitting a second copy of any
Access-Control-*header.
If those aren’t in order first, Access-Control-Max-Age will be caching an inconsistent or incorrect permission grant.
Step-by-step fix
Step 1 — Express.js (cors middleware)
const cors = require('cors');
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 600
}));
maxAge: 600 maps directly to Access-Control-Max-Age: 600 in the OPTIONS response.
Step 2 — Nginx
location /api/ {
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods 'GET, POST, PUT' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization' always;
add_header Access-Control-Allow-Credentials 'true' always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
proxy_pass http://backend;
}
The always flag ensures headers are emitted on all response codes, including 4xx from the upstream. The Vary: Origin header prevents CDN cache poisoning across tenants — see the proxy bypass strategies section for edge-layer considerations.
Step 3 — AWS CloudFront
CloudFront requires an Origin Response Policy to inject custom headers at the edge:
- In the CloudFront console, open Policies → Response headers.
- Create a policy with
Access-Control-Max-Age: 600under Custom headers. - Attach the policy to your distribution behavior (not the origin).
- Ensure
Vary: Originis included — without it CloudFront may serve a cached preflight response intended for a different origin to a new requesting origin.
Verification
curl check
curl -si -X OPTIONS \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type, Authorization' \
https://api.example.com/data \
| grep -i 'access-control-max-age\|status\|vary'
Expected output:
HTTP/2 204
access-control-max-age: 600
vary: Origin
DevTools check
Security boundary note
Do not set Access-Control-Max-Age to 0 or omit it on every route just for safety, and do not set it above 600 expecting extra coverage. The real risk is long durations on high-sensitivity endpoints: when a user’s token is revoked, Access-Control-Max-Age only caches the permission to make the request, not the token itself. The actual GET or POST will still return 401 if the token is invalid. However, a long cache means a revoked token’s scope change (e.g., removing a method from the allowed list) takes effect slowly — browsers won’t re-check until the cached grant expires.
| Endpoint sensitivity | Recommended value | Reason |
|---|---|---|
| Public / static data | 600 s | Cross-browser maximum; negligible security risk |
| Authenticated API | 60–300 s | Allows timely permission changes without excessive preflight overhead |
| Admin / high-privilege | 0 s or omit header | Forces re-validation on every request |
Common mistakes
| Mistake | What actually happens | Fix |
|---|---|---|
Setting maxAge above 600 expecting Chrome to respect it |
Chrome and Safari silently clamp to 600; no extra caching benefit is gained | Use 600 s as the ceiling |
Emitting duplicate Access-Control-Max-Age headers via proxy stacking |
Browser may discard both values or pick the first, bypassing the cache | Audit the full response pipeline; ensure a single add_header directive |
Omitting Vary: Origin when reflecting dynamic origins |
CDN serves one tenant’s preflight grant to a different origin, breaking or spoofing CORS | Always pair a reflected origin with Vary: Origin |
| Applying long max-age to routes whose allowed headers change frequently | Browsers continue using the cached (stale) header list until TTL expires | Use short TTLs (60 s) on routes under active header-set churn |
FAQ
What is the optimal Access-Control-Max-Age value for production APIs?
600 seconds is the cross-browser safe maximum. It stays within the Chrome and Safari caps, gives Firefox users the full window, and leaves enough room for timely credential and session-policy rotation. Only use lower values on endpoints where the allowed method or header set changes frequently.
Does Access-Control-Max-Age cache the actual response or just the preflight?
It caches only the OPTIONS preflight permission check — whether the specific combination of origin, method, and headers is permitted. Actual GET or POST responses are governed by separate HTTP caching headers (Cache-Control, ETag, etc.) and are not affected by this header.
How do I force a browser to clear a cached preflight during testing?
Enabling Disable cache in Chrome DevTools clears the preflight cache for that tab. Alternatively, open an incognito window or append a version segment to the URL path (e.g., /api/v2/data instead of /api/v1/data). There is no server-side header that explicitly purges a client-side preflight cache entry.
Related
- Cache Duration Tuning & Max-Age — parent: browser cap mechanics, TTL strategy, and server-side configuration patterns
- Header Deduplication Techniques — eliminating duplicate
Access-Control-*headers that break preflight caching - OPTIONS Endpoint Design — architecting low-overhead preflight responders
- Designing Lightweight OPTIONS Endpoints — framework-specific patterns for minimal preflight response cost
- Proxy Bypass Strategies for CORS Preflight — how edge proxies and CDNs interact with preflight caching