Decoding Safari CORS Error Messages
The console lines this page decodes:
[Error] Origin https://console.helioscope.app is not allowed by Access-Control-Allow-Origin. Status code: 200
[Error] Fetch API cannot load https://api.helioscope.dev/v2/projects due to access control checks.
[Error] Preflight response is not successful. Status code: 404
[Error] Request header field x-workspace-id is not allowed by Access-Control-Allow-Headers.
[Error] XMLHttpRequest cannot load https://api.helioscope.dev/v2/exports due to access control checks.
[Error] TypeError: Load failed
Root Cause
WebKit runs the same CORS algorithm as every other engine, but it reports the outcome in two separate console lines and omits the explanatory clause the other engines print. The first line names the check that failed; the second line names only the API that gave up — Fetch API cannot load … due to access control checks carries no diagnostic value at all and is emitted identically for a missing header, a rejected method, and a failed preflight. Developers routinely read the second line, conclude that Safari has blocked the request for an unknown reason, and start changing server configuration at random. The signal is always in the first line and in the numeric status appended to it, which is the status of the response WebKit received and then refused to hand to JavaScript.
This page is the WebKit-specific companion to Decoding Browser CORS Error Messages, which maps the equivalent Chrome and Firefox strings to the same underlying Fetch steps. Everything below assumes a page served from https://console.helioscope.app calling an API on https://api.helioscope.dev.
Because the WebKit wording shares almost no vocabulary with Blink’s, the fastest way to work is to translate the phrase first and only then reason about the fault:
Prerequisite State
- Safari’s Develop menu is enabled (Settings → Advanced → “Show features for web developers”), so Web Inspector and the cross-origin toggle are reachable.
- For an iPhone or iPad reproduction, Web Inspector is enabled on the device (Settings → Apps → Safari → Advanced → Web Inspector) and the device is attached to a Mac.
curlis available, and you know which layer terminates the preflight — the application, a reverse proxy, or a CDN.- The failing call is reproducible on demand; a one-off failure during a deploy is usually a stale preflight rather than a header fault.
Step-by-Step Fix
Step 1 — Capture both console lines and the status
Open Web Inspector (⌥⌘I), select the Console tab, enable Preserve Log, and reproduce the call. Copy the whole pair of lines. The trailing Status code: value is the diagnostic part: 200 means the server answered normally and the fault is in the response headers, while 404, 401, or 405 after Preflight response is not successful means the OPTIONS request never reached a handler that answers preflights.
Step 2 — Accept that the preflight is invisible in the Network tab
WebKit generates the preflight internally, so it is not a resource load your page requested and no row appears for it. Everything you can observe about the OPTIONS exchange in Safari is that single console line:
Step 3 — Reproduce the preflight with curl
Send exactly the request WebKit would send. The header names in Access-Control-Request-Headers must be lower-cased and comma-separated, matching what the browser generates:
curl -sS -D - -o /dev/null -X OPTIONS https://api.helioscope.dev/v2/projects \
-H 'Origin: https://console.helioscope.app' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: authorization, x-workspace-id'
A healthy answer is a 204 (or 200) carrying Access-Control-Allow-Origin, Access-Control-Allow-Methods including PATCH, and Access-Control-Allow-Headers listing both authorization and x-workspace-id. The full curl reproduction technique covers the flags for redirects and HTTP/2 negotiation.
Step 4 — Confirm the diagnosis with the Develop menu toggle
Safari has a switch the other engines lack: Develop → Disable Cross-Origin Restrictions. Turn it on and reload. If the call now succeeds, the fault is definitively in the CORS headers; if it still fails, you are looking at a transport, TLS, or application error that CORS was merely reporting. Turn the switch off again immediately — it only affects your own machine and it hides every future CORS regression from you.
The triage below is the whole Safari decision in one pass:
Step 5 — Emit an explicit Allow-Headers list and a 2xx preflight
Two server-side changes clear the overwhelming majority of Safari-only failures: answer OPTIONS on the route at all, and list every custom header by name instead of relying on *. WebKit matches Access-Control-Allow-Headers: * literally when the request is credentialed, so a wildcard that satisfies Chrome still rejects x-workspace-id in Safari.
const express = require("express");
const app = express();
const ALLOWED_ORIGINS = new Set([
"https://console.helioscope.app",
"https://staging.helioscope.app",
]);
// Regex path works in Express 4 and 5 alike; mounted before any auth middleware.
app.options(/^\/v2\//, (req, res) => {
const origin = req.headers.origin;
if (!ALLOWED_ORIGINS.has(origin)) 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",
// Named explicitly: WebKit does not expand "*" on credentialed requests
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Workspace-Id",
"Access-Control-Max-Age": "600",
"Vary": "Origin",
});
return res.status(204).end();
});
The same policy at the reverse proxy, for stacks where the application never sees OPTIONS:
map $http_origin $cors_origin {
default "";
"https://console.helioscope.app" $http_origin;
"https://staging.helioscope.app" $http_origin;
}
server {
listen 443 ssl;
server_name api.helioscope.dev;
location /v2/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" 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 "600" always;
add_header Vary "Origin" always;
return 204;
}
proxy_pass http://api_upstream;
}
}
600 is not an arbitrary number: WebKit clamps Access-Control-Max-Age to 600 seconds, so anything larger buys nothing for Safari users. The reasoning behind the value is in How to Set Access-Control-Max-Age Effectively.
Verification
Run the preflight probe and the actual-request probe, then repeat the failing flow in Safari with Web Inspector open:
curl -sS -D - -o /dev/null -X OPTIONS https://api.helioscope.dev/v2/projects \
-H 'Origin: https://console.helioscope.app' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: authorization, x-workspace-id' \
| grep -iE '^(HTTP|access-control|vary)'
Security Boundary Note
Never ship a fix that only works because cross-origin restrictions were disabled in your own browser, and never “solve” a Safari failure by returning Access-Control-Allow-Origin: * on a route that sets cookies — WebKit will reject the combination outright, and on the engines that accept it you have opened credentialed reads to every site on the internet. If the console pair disappears only when tracking prevention is off, the answer is a first-party arrangement for the cookie, not a wider CORS policy; the trade-offs are laid out in SameSite=None vs CORS Credentials. Keep the origin allowlist exact, as described in Access-Control Header Directives.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
Reading due to access control checks as the diagnosis |
The line is identical for every CORS fault, so the search starts with no information | Scroll to the line above it and read the named header or method |
Treating Status code: 200 as proof the request succeeded |
The status describes the response WebKit refused to expose, not the verdict | Use the status only to tell a header fault from a missing OPTIONS route |
| Hunting for the preflight row in Safari’s Network tab | Time lost looking for an exchange the tab never lists | Reproduce the OPTIONS request with curl instead |
Relying on Access-Control-Allow-Headers: * |
WebKit matches the asterisk literally on credentialed requests and rejects the real header | List every custom header by name |
| Leaving Disable Cross-Origin Restrictions enabled | Every later CORS regression is invisible on the machine that would have caught it | Toggle it on only for the diagnostic step, then off |
FAQ
Why does Safari print Status code: 200 on a request that failed?
The number is the HTTP status of the response WebKit received and then refused to expose, not the cause of the refusal. A CORS failure is decided after the response arrives, so a perfectly healthy 200 with a JSON body is still blocked when Access-Control-Allow-Origin is missing or does not match. Read the status as evidence that the server answered normally and the fault is in the headers, not in the route.
Why can I not see the OPTIONS preflight in Safari’s Network tab?
Safari’s Web Inspector lists the resource loads the page requested, and the preflight is generated internally by WebKit rather than by your code, so the OPTIONS exchange has no row of its own. The console line Preflight response is not successful is your only in-browser evidence that one happened. Reproduce the exact OPTIONS request with curl to see its status and headers.
My API works in Chrome but fails in Safari — is WebKit stricter about CORS?
WebKit implements the same Fetch algorithm, but it differs in three practical ways: it caps Access-Control-Max-Age at 600 seconds, it treats a literal asterisk in Access-Control-Allow-Headers as a header name rather than a wildcard on credentialed requests, and its tracking prevention can withhold third-party cookies so a credentialed request arrives unauthenticated. Each of those looks like extra strictness but is a configuration difference you can fix on the server.