Choosing Access-Control-Max-Age for Fast-Changing APIs

The failure this page prevents:

Access to fetch at 'https://api.orbitpay.io/v3/payouts/38c1' from origin
'https://dash.orbitpay.io' has been blocked by CORS policy: Method PATCH is
not allowed by Access-Control-Allow-Methods in preflight response.

The confusing part is that the server does allow PATCH on that route. It started allowing it four minutes ago, when the workspace was upgraded to a plan that includes edits. The browser is not asking — it is acting on a preflight it cached before the upgrade, and it will keep doing so until the entry expires.

Root Cause

A preflight cache entry is keyed on the request origin, the request URL, the credentials mode, and the specific method or header name the browser asked about. It is not keyed on anything that identifies who is asking: no Authorization header, no session cookie, no workspace identifier, no build number of the code that produced the answer. So the moment your allowed methods, allowed headers or origin allowlist can change faster than the cache expires — because of a plan upgrade, a feature flag, a per-tenant policy, or simply a frequent deploy — the browser is answering a question with data that predates the change. The specification treats this as intended behaviour: Access-Control-Max-Age is a promise that the answer stays valid for that long, and a fast-changing API is one that cannot honour a long promise.

This page narrows the general tuning advice in Cache Duration Tuning & Max-Age to the volatile case, where the correct number is derived from your deploy and entitlement cadence rather than from the browser cap alone. If you have not yet set the header at all, start with How to Set Access-Control-Max-Age Effectively and come back for the volatility question.

What is in the key — and, more importantly, what is not — decides which changes the cache can even notice:

What the preflight cache keys on, and what it ignores Two panels side by side. The left panel lists the four inputs that form a preflight cache entry: origin, URL, credentials mode, and the method or header name asked about. The right panel lists four inputs that are absent from the key, including the bearer token, tenant, user role and deploy version. One cached grant per key — everything outside the key is invisible to it In the key Not in the key Origin: https://dash.orbitpay.io Full URL, path and query included Credentials mode of the request The method or header name asked about Authorization header or bearer token Session cookie and workspace id Plan, role and feature-flag state Build number of the code that answered A change that only moves a right-hand item cannot invalidate the entry — it survives for the full Max-Age

Prerequisite State

Step-by-Step

Step 1 — Classify each route by policy volatility

Not every route on a fast-changing API is fast-changing. Sort them into four classes and give each class one number:

Route class Example What can change under it Max-Age
Static contract GET /v3/reference/currencies nothing without a versioned release 600
Deploy-coupled POST /v3/payouts allowed headers change with each deploy 120
Entitlement-gated PATCH /v3/payouts/{id} methods depend on the workspace plan 30
Revocable admin DELETE /v3/admin/members/{id} role can be withdrawn mid-session 0

The last row is the only place a zero belongs. Everywhere else, zero trades a real latency cost for a guarantee you can get more cheaply.

Step 2 — Take the ceiling as the smallest of three windows

The value you ship is not a preference; it is the minimum of three hard limits:

  1. The engine cap. Blink and WebKit clamp to 600 seconds, Gecko to 86 400. Anything above 600 only lengthens the window for a minority of users, and it lengthens exactly the window you are trying to shorten.
  2. The rollback window. If a bad policy takes you eight minutes to detect and revert, a 600-second cache means the last affected client keeps acting on it for ten minutes after the revert lands.
  3. The entitlement window. If a plan upgrade must take effect within a minute of payment, no route whose methods depend on the plan may cache longer than a minute.

The exposure is not average, it is worst case: every client caches at a different moment, so the last one to expire defines when the fleet is consistent again.

Worst-case staleness window after a policy change Three horizontal bands on a twelve-minute axis, one per client. Two clients cached their grant before the policy change and keep honouring the old policy for the remainder of their 600-second window, while a third client that preflights after the change gets the new policy immediately. Access-Control-Max-Age: 600 — three clients, one policy change at minute 2 policy change deploys Client A still honouring the old grant for eight more minutes Client B cached one minute later, so it goes stale one minute later too Client C first preflight after the change — correct policy immediately worst case: a client acts on the old policy for the full Max-Age after the deploy 0 2 4 6 8 10 12 min

Step 3 — Emit the value per route, not per server

One global number forces the whole API down to the volatility of its worst route. Drive the value from the path in whichever layer answers OPTIONS.

Express, with the table from Step 1 encoded as prefixes:

const PREFLIGHT_TTL = [
  [/^\/v3\/admin\//, 0],
  [/^\/v3\/payouts\//, 30],
  [/^\/v3\/reference\//, 600],
];

function ttlFor(path) {
  const hit = PREFLIGHT_TTL.find(([re]) => re.test(path));
  return String(hit ? hit[1] : 120); // deploy-coupled default
}

app.options(/^\/v3\//, (req, res) => {
  const origin = req.headers.origin;
  if (origin !== "https://dash.orbitpay.io") return res.status(403).end();
  res.set({
    "Access-Control-Allow-Origin": origin,
    "Access-Control-Allow-Credentials": "true",
    "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Workspace-Id",
    "Access-Control-Max-Age": ttlFor(req.path),
    "Vary": "Origin",
  });
  res.status(204).end();
});

Nginx, using a map so the value is chosen before the request reaches a handler:

map $uri $preflight_ttl {
    default            "120";
    "~^/v3/admin/"     "0";
    "~^/v3/payouts/"   "30";
    "~^/v3/reference/" "600";
}

location /v3/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin  "https://dash.orbitpay.io" always;
        add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Workspace-Id" always;
        add_header Access-Control-Max-Age       $preflight_ttl always;
        add_header Vary                         "Origin" always;
        return 204;
    }
    proxy_pass http://payouts_upstream;
}

The same rule in a Go service, where the middleware owns both the preflight and the actual response:

package main

import (
	"net/http"
	"strings"
)

func ttlFor(path string) string {
	switch {
	case strings.HasPrefix(path, "/v3/admin/"):
		return "0"
	case strings.HasPrefix(path, "/v3/payouts/"):
		return "30"
	case strings.HasPrefix(path, "/v3/reference/"):
		return "600"
	default:
		return "120"
	}
}

func withCORS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Origin") != "https://dash.orbitpay.io" {
			next.ServeHTTP(w, r)
			return
		}
		h := w.Header()
		h.Set("Access-Control-Allow-Origin", "https://dash.orbitpay.io")
		h.Set("Access-Control-Allow-Credentials", "true")
		h.Add("Vary", "Origin")

		if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
			h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
			h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Workspace-Id")
			h.Set("Access-Control-Max-Age", ttlFor(r.URL.Path))
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Step 4 — Shrink before you change, restore after

The number is only half the technique. A policy change shipped while a long value is in flight is a change that arrives at each client at a different, unknowable moment. Sequence the two so the cache is already short when the change lands:

Shrink, change, restore Three phases connected left to right. First the Max-Age value is lowered on its own and allowed to drain for the length of the previous value. Then the policy change ships against a short cache. Finally the original value is restored once the change is verified. Never change the policy while a long Max-Age is still in flight 1. Shrink Max-Age: 10, shipped alone, no other change to the CORS policy drain 600 s 2. Change new methods, headers or origin allowlist against a 10 s cache soak + verify 3. Restore put the route back to its classified value once metrics are clean Phase 1 must run for at least the length of the value it replaces, or old entries outlive it

Step 5 — Measure the reuse you are actually buying

A short value is only expensive if clients preflight often. Count the methods in the access log and compare the number of OPTIONS requests with the number of non-simple requests they authorised:

awk -F'"' '{split($2, r, " "); m[r[1]]++} END {for (k in m) printf "%-8s %d\n", k, m[k]}' \
  /var/log/nginx/access.log

A ratio near one means every write is paying for its own preflight and the value is too low for that route class. A ratio above five means the cache is doing real work and you can afford to shorten it further if volatility demands. Route-level latency attribution is covered in Measuring CORS Preflight Latency in Production, and the complementary trick of shrinking the number of distinct preflights is in Reducing Preflight Frequency with Header Caching.

Verification

curl -sS -D - -o /dev/null -X OPTIONS https://api.orbitpay.io/v3/payouts/38c1 \
  -H 'Origin: https://dash.orbitpay.io' \
  -H 'Access-Control-Request-Method: PATCH' \
  -H 'Access-Control-Request-Headers: authorization, x-workspace-id' \
  | grep -iE '^(HTTP|access-control-max-age|vary)'

Security Boundary Note

Access-Control-Max-Age is a performance dial, never an access-control dial. A cached grant does not let the browser read anything — the actual request still travels to your server, which must re-authorise it on every call. The real risk of a long value on a volatile API is the reverse: an origin you removed from the allowlist keeps skipping the preflight and keeps sending credentialed requests, so your logs fill with rejections and your incident timeline stretches by the length of the cache. Revoke on the actual response, keep the allowlist exact as described in Dynamic Origin Validation Patterns, and never widen Access-Control-Allow-Methods merely to avoid re-preflighting after a policy change.

Common Mistakes

Mistake Technical impact Fix
One global Access-Control-Max-Age The whole API inherits the volatility of its least stable route Emit the value per path class from the layer answering OPTIONS
Shipping the policy change and the shorter value together Clients still hold the old long entry, so the shorter value only applies after the change has already gone stale Ship the shrink first and let it drain for the length of the old value
Assuming a plan upgrade invalidates the cached grant Entitlement is not part of the cache key, so the browser keeps using the pre-upgrade answer Cap entitlement-gated routes at the entitlement window
Setting 0 everywhere to be safe A preflight before every write, doubling request count and adding a round trip per call Reserve 0 for revocable admin routes; use 10–60 s elsewhere

FAQ

Does the preflight cache key include the user’s session or bearer token?

No. The browser keys a preflight entry on the origin, the request URL, the credentials mode, and the method or header name that was asked about. Authorization headers, cookies, tenant identifiers and user roles are not part of the key, so two users of the same page share one cached grant for the same URL. Any policy that varies per user or per plan is therefore served from a cache that cannot see the thing it varies on.

Is a short Max-Age enough to revoke access from an origin I just removed?

It shortens the window but it is not a revocation mechanism. A cached preflight only lets the browser skip the OPTIONS round trip; the actual request still reaches your server, which must re-check the origin and reject it. Treat Access-Control-Max-Age as an upper bound on how long a client keeps sending requests you will refuse, and enforce the removal on the actual response rather than relying on the cache expiring.

Should I just set Access-Control-Max-Age: 0 on a fast-changing API?

Only on the routes whose policy genuinely changes between requests, such as admin actions gated on a role that can be revoked mid-session. Zero means a preflight before every non-simple call, which doubles the request count and adds a full round trip to each write. Most fast-changing APIs are better served by a value between 10 and 60 seconds, which removes the burst of preflights during a page load while keeping the stale window inside a deploy cycle.