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

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.

Annotated preflight response: what to check before the number A raw OPTIONS response is shown as a block of header lines. Four callouts point at the status line, a duplicated Access-Control-Max-Age header, a value carrying a unit suffix, and an absent Access-Control-Allow-Methods header, each labelled with the effect it has on caching. HTTP/2 301 location: /v1/decks/ access-control-allow-origin: https://studio.quilldeck.io access-control-allow-headers: content-type, authorization access-control-max-age: 10m access-control-max-age: 5 (no access-control-allow-methods) a redirect ends the preflight — nothing is stored "10m" is not ASCII digits — parse fails, 5 s default two Max-Age lines — a second layer is writing one no Allow-Methods — the method check cannot pass A response that qualifies: status 204, no location header, one bare-integer Max-Age line, an exact-match Allow-Origin, and Allow-Methods and Allow-Headers covering the request. Three of these four defects produce no console message 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:

The three states of a preflight cache entry Three states are laid out left to right: no entry, entry live, and entry expired. Arrows move between them when a preflight succeeds and when the TTL elapses, a self-loop on the live state marks silent reuse, and two return edges show eviction and the next preflight. No entry Entry live Entry expired preflight qualifies and the value parses the clamped TTL elapses every matching request is served from the entry — no OPTIONS is sent evicted early: network change, cleared site data, browser restart the next non-simple call preflights again, and the cycle restarts

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.

From header value to stored TTL: three places it can be lost A left-to-right pipeline runs from the response arriving through a qualification gate, a parse gate and a clamp gate to a stored entry. Four sample inputs are tracked through the pipeline, each dropping out at the stage that discards it. OPTIONS response arrives qualify 2xx, no redirect, origin and method ok parse ASCII digits only, exactly one line clamp to the engine's own ceiling entry stored HTTP/2 301 preflight fails, no entry 10m falls back to a 5 s entry 86400 stored as 600 s in Blink Only the first gate reports anything to the console. A value that survives to the clamp stage is working correctly — it is simply working to the engine's number rather than to yours, and the interval you observe is the proof.

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.