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.


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.

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.