Why Your Access-Control-Max-Age Is Ignored
Symptom: the header is right there in the response, and the browser preflights anyway. Sometimes there is no console message at all and just a stubborn OPTIONS row on every call; sometimes there is one, and it names the real reason:
Access to fetch at 'https://api.quilldeck.io/v1/decks' from origin
'https://studio.quilldeck.io' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check: Redirect is not allowed for a preflight
request.
Either way, the value you configured is not governing anything, and the network panel is the only place that shows it.
Root Cause
“Ignored” covers three distinct failures, and they need three different fixes. The value may have been clamped — accepted, stored, but shortened to the engine’s implementation-defined maximum, so a 86400 becomes 600 in Chrome and Safari. It may have failed to parse — the Fetch Standard requires a sequence of ASCII digits, so 10m, "600" and 600s are all invalid and the engine falls back to its five-second default. Or the whole preflight response may have been disqualified, in which case nothing is stored at any TTL: a redirect, a non-2xx status, a failed origin check or a failed method check all end the preflight before caching is even considered.
Only the third failure produces a console message. The first two are entirely silent, which is why the diagnosis below starts at the wire and works upward. This page belongs to Browser Preflight Cache Limits, which documents the ceilings and key rules each engine applies.
Prerequisite State
- The public hostname is reachable from your shell with
curl, and you know which layer terminates TLS. - You can read the application’s own logs, so you can tell whether the
OPTIONSrequest arrived at your code or was answered by a proxy. - A browser with DevTools open on the failing page, so you can correlate the wire evidence with an actual preflight interval.
- The value you believe you configured, written down, so you can compare it with the value that is actually on the wire.
Step-by-Step Fix
Step 1 — Read the bytes, not the config
Run this against the hostname the browser uses, not against the application port. A proxy in between is the single most common reason the configured value never reaches anyone.
curl -sSi -X OPTIONS https://api.quilldeck.io/v1/decks \
-H 'Origin: https://studio.quilldeck.io' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: content-type, authorization' \
| sed -n '1,40p'
Read the output against the four disqualifiers below before you look at the number at all.
Step 2 — Make the value a bare decimal integer
Template engines and configuration languages love to add quotes and units. The parser accepts neither. Fix it at the source rather than downstream.
# /etc/httpd/conf.d/quilldeck-cors.conf — Apache httpd 2.4
# Match the exact origin; never reflect blindly.
SetEnvIf Origin "^https://studio\.quilldeck\.io$" QUILL_ORIGIN=$0
Header always set Access-Control-Allow-Origin %{QUILL_ORIGIN}e env=QUILL_ORIGIN
Header always append Vary Origin
# "always set" replaces any value an upstream module already wrote,
# which is what stops the duplicate header seen in Step 1.
Header always set Access-Control-Allow-Methods "GET, POST, PATCH, DELETE"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization"
Header always set Access-Control-Max-Age "600"
# Answer the preflight here so no redirect or rewrite can intercept it.
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^ - [R=204,L]
The quotes in Header always set … "600" are Apache configuration syntax and are stripped before the header is written; what reaches the wire is access-control-max-age: 600. Confirm that with the curl from Step 1 rather than assuming it — a framework that writes the value as a JSON-encoded string will ship the quote characters, and that is a parse failure.
Step 3 — Stop the response being disqualified
The redirect in the sample output is the highest-severity finding, because it means no TTL will ever apply. A preflight that receives a 3xx fails outright, and the browser will not follow it. Trailing-slash canonicalisation is the usual culprit: the client requests /v1/decks and the framework redirects to /v1/decks/.
# app.py — Flask, answering OPTIONS before any route canonicalisation
from flask import Flask, request, make_response
app = Flask(__name__)
# Do not let Werkzeug redirect /v1/decks to /v1/decks/ during a preflight.
app.url_map.strict_slashes = False
ALLOWED_ORIGIN = "https://studio.quilldeck.io"
@app.before_request
def short_circuit_preflight():
if request.method != "OPTIONS":
return None
if request.headers.get("Access-Control-Request-Method") is None:
return None
response = make_response("", 204)
response.headers["Vary"] = "Origin"
if request.headers.get("Origin") == ALLOWED_ORIGIN:
response.headers["Access-Control-Allow-Origin"] = ALLOWED_ORIGIN
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH, DELETE"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
response.headers["Access-Control-Max-Age"] = "600"
return response
@app.route("/v1/decks", methods=["GET", "POST", "PATCH", "DELETE"])
def decks():
return {"decks": []}
A before_request hook runs ahead of routing, so the preflight never reaches the redirect logic, the authentication decorator, or anything else that might answer with a 401. The same reasoning is developed at length in How to Design Lightweight OPTIONS Endpoints That Bypass Middleware.
Once the response qualifies, an entry exists and moves between three states for the rest of its life. Knowing which state you are in tells you whether to look at the server or at the browser:
If you never observe the self-loop — that is, an OPTIONS fires on every call — the entry is not being created, and the answer is in Steps 1 to 3. If the loop works but ends sooner than configured, the entry is being created correctly and the answer is in Step 4.
Step 4 — Do the ceiling arithmetic
Once the response qualifies and the value parses, what remains is clamping, and clamping is deterministic. Compute the effective TTL before you go looking for a bug.
| Value on the wire | Blink stores | Gecko stores | WebKit stores | What you observe |
|---|---|---|---|---|
600 |
600 s | 600 s | 600 s | consistent ten-minute interval everywhere |
3600 |
600 s | 3 600 s | 600 s | Chrome and Safari re-preflight six times per Firefox preflight |
86400 |
600 s | 86 400 s | 600 s | the widest divergence a single value can produce |
0 |
nothing | nothing | nothing | an OPTIONS before every single call |
10m |
5 s | 5 s | 5 s | looks like no caching at all under normal click cadence |
| header absent | 5 s | 5 s | 5 s | identical symptom to 10m, different cause |
The two bottom rows explain most “the header does nothing” reports: a five-second entry deduplicates a burst of parallel calls on page load and then expires before the user clicks anything, so the caching appears to work in a load test and to fail in real use.
Step 5 — Find whichever layer is writing the second value
If Step 1 showed two access-control-max-age lines, one of them belongs to something you did not configure. Bisect by asking each layer directly.
# Ask the application, bypassing the proxy entirely.
curl -sSi -X OPTIONS http://127.0.0.1:8000/v1/decks \
-H 'Origin: https://studio.quilldeck.io' \
-H 'Access-Control-Request-Method: PATCH' | grep -ci 'access-control-max-age'
# Ask the public hostname.
curl -sSi -X OPTIONS https://api.quilldeck.io/v1/decks \
-H 'Origin: https://studio.quilldeck.io' \
-H 'Access-Control-Request-Method: PATCH' | grep -ci 'access-control-max-age'
A count of 1 from the application and 2 from the public hostname puts the extra header in the proxy or CDN. A count of 2 from the application means two pieces of middleware in your own stack are both writing CORS headers — remove one. Behaviour when two values conflict is not defined by the standard, so no engine’s choice is a bug you can appeal; the same class of failure is dissected in Fixing Duplicate Access-Control-Allow-Origin Headers.
The duplicate is usually a framework helper nobody remembers enabling. A CORS middleware package installed months ago for one route often applies globally with its own default TTL, and it will keep re-adding the header no matter what the web server configuration says. Decide which single layer owns CORS for the service, delete the header-writing code from every other layer, and note the decision in the repository so it survives the next dependency upgrade.
Step 6 — Pin the behaviour with a test
Once the response is correct, the cheapest way to keep it correct is an assertion on the raw bytes. This runs against a live URL and fails on all three defects at once: a disqualifying status, a non-integer value, and a duplicate header line.
# test_preflight_headers.py — pytest, runs against a deployed environment
import os
import requests
BASE = os.environ.get("API_BASE", "https://api.quilldeck.io")
ORIGIN = "https://studio.quilldeck.io"
def test_preflight_is_cacheable():
response = requests.options(
f"{BASE}/v1/decks",
headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "PATCH",
"Access-Control-Request-Headers": "content-type, authorization",
},
allow_redirects=False,
)
assert response.status_code in (200, 204), "a non-2xx preflight is never cached"
assert "location" not in response.headers, "a redirected preflight fails outright"
# requests joins repeated headers with ", " — a comma proves a duplicate line.
max_age = response.headers.get("Access-Control-Max-Age", "")
assert "," not in max_age, f"two layers wrote Max-Age: {max_age!r}"
assert max_age.isdigit(), f"value must be bare ASCII digits, got {max_age!r}"
assert int(max_age) > 0, "zero disables preflight caching entirely"
assert "PATCH" in response.headers.get("Access-Control-Allow-Methods", "")
assert response.headers.get("Access-Control-Allow-Origin") == ORIGIN
Run it against staging on every deploy. It costs one request and catches the regression on the day a proxy configuration changes, rather than the week someone notices the preflight volume graph.
Verification
curl — a clean response, in one command:
curl -sSi -X OPTIONS https://api.quilldeck.io/v1/decks \
-H 'Origin: https://studio.quilldeck.io' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: content-type, authorization' \
| grep -iE '^HTTP/|^location:|^access-control-|^vary:'
Expected: HTTP/2 204, no location line, exactly one access-control-max-age: 600, and access-control-allow-methods containing PATCH.
DevTools — confirm the interval matches the arithmetic:
Security Boundary Note
Two of the fixes above are worth a second look before they ship. Answering OPTIONS in a before_request hook deliberately bypasses authentication, which is correct — a preflight carries no credentials the server could check — but it must not bypass the origin check as well. The Flask code above emits CORS headers only when the origin matches exactly; a version that emits them unconditionally turns the endpoint into a permission oracle. Second, a long TTL is not a security setting: it does not grant anything the actual request would not, but it does mean a narrowing of your policy will not reach browsers until entries expire. Enforce narrowings on the real request handler. The full reasoning is set out in Wildcard CORS Risks and Safe Origin Allowlisting.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Testing against the application port instead of the public hostname | The proxy’s duplicate or shortened header never appears in your test, so the real value stays invisible | Always run the diagnostic curl against the hostname the browser uses |
| Emitting the value as a quoted or unit-suffixed string | Parsing fails silently and the engine falls back to a five-second entry, which looks like no caching at all | Emit bare ASCII digits; assert on the raw header bytes in a test |
| Leaving trailing-slash canonicalisation in front of the preflight | A 3xx response fails the preflight outright; no TTL can apply |
Short-circuit OPTIONS before routing and canonicalisation run |
| Reading a shorter-than-configured interval as a browser bug | The clamp is specified behaviour and identical across releases | Compute the effective TTL from the ceiling table before investigating further |
FAQ
Does the browser warn me when it clamps or discards the value?
No. Clamping and parse failure are both silent in every engine. The only observable is the timing of the next OPTIONS request, which is why the diagnosis has to start with the raw response bytes rather than the console. The one case that does produce a message is disqualification — a redirect, a non-2xx status or a failed origin check — and that message describes the preflight failing, not the TTL being lost.
Is Access-Control-Max-Age: 0 different from omitting the header?
Yes. Zero means do not cache at all, so every non-simple request pays a preflight. Omitting the header falls back to the engine default of about five seconds, which still deduplicates a burst of parallel calls. If you want no caching, send zero explicitly rather than deleting the header — the explicit value is also the one your tests can assert on, and it documents the intent for the next reader.
Can a proxy strip or rewrite the header before the browser sees it?
Yes, and this is common. Reverse proxies and edge platforms frequently add their own CORS headers, which either duplicates Access-Control-Max-Age or replaces your value with a shorter default. Always run the diagnostic curl against the public hostname, not against the application port behind the proxy, and compare the header count from both. Terminating the preflight deliberately at one layer, as described in Proxy Bypass Strategies for CORS Preflight, removes the ambiguity entirely.