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.

Preflight cache decision flow Diagram showing the browser preflight cache lookup: on a cache hit the actual request is sent directly; on a cache miss an OPTIONS request is made, the result cached, then the actual request proceeds. Cross-origin request issued Check preflight cache Send OPTIONS, cache result Send actual request directly miss hit

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.

Sending an ambitious value does not produce an error anywhere — it produces a quietly shorter cache window on two of the three engines:

How much of a 3600-second Max-Age each engine honours Three horizontal tracks, one per browser engine, each representing the 3600 seconds the server asked for. Blink and WebKit fill only the first 600 seconds of their track while Gecko fills the whole track, showing where the silent clamp lands. Server sends Access-Control-Max-Age: 3600 on every OPTIONS response engine cap Chrome / Edge (Blink) 600 s honoured, 3000 s silently discarded Safari (WebKit) clamped at the same 600 s ceiling as Blink Firefox (Gecko) all 3600 s honoured — Gecko's ceiling is 86 400 s 0 600 s 1800 s 3600 s No console warning marks the clamp — the header is accepted and the excess is dropped.

Prerequisite state

Before setting Access-Control-Max-Age, verify that:

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:

  1. In the CloudFront console, open Policies → Response headers.
  2. Create a policy with Access-Control-Max-Age: 600 under Custom headers.
  3. Attach the policy to your distribution behavior (not the origin).
  4. Ensure Vary: Origin is 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.

Laid out on a timeline, the exposure is a bounded window rather than an open-ended one, and it ends without any action from you:

Policy-change lag inside a 600-second preflight cache Two parallel timelines over 900 seconds. The upper track shows the browser's preflight cache going stale when the server policy changes at 180 seconds and staying stale until the 600-second TTL expires. The lower track shows the server switching from policy v1 to v2 at that moment. DELETE removed from the allowlist at t = 180 s, with Access-Control-Max-Age: 600 Browser preflight cache grant cached DELETE allowed grant is now stale browser skips OPTIONS, still sends DELETE TTL expires fresh OPTIONS, new grant Server policy policy v1 GET POST DELETE policy v2 deployed Allow-Methods: GET, POST lag window: 420 s of stale permission Max-Age caches permission to ask, never authorization to act — the DELETE itself still fails at the origin.
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.