Fixing CORS Errors on a Spatial API

On this page

Problem statement

The API works in curl, in Postman and in a Python client. In a browser:

Access to fetch at 'https://api.example.org/features' from origin
'https://maps.example.org' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

The request reached the server, the server answered correctly, and the browser threw the response away. That is the part that makes CORS confusing: it is not a server error and not a network error β€” it is the browser enforcing a rule the server did not opt out of.

For spatial APIs there are three variants worth telling apart: the simple request that is missing a header, the preflighted request that fails on OPTIONS, and the tile or canvas case where the image loads and then taints the canvas. Everything below was tested: a small FastAPI service returning Rutland's 15 wards as GeoJSON and a map tile, run under four CORS configurations and called from a page on another origin in headless Chromium.

Quick answer

from fastapi.middleware.cors import CORSMiddleware

RECOMMENDED = dict(
    allow_origins=[SITE, "http://localhost:5173"],     # the map's origin, and development
    allow_methods=["GET", "OPTIONS"],
    allow_headers=["Content-Type", "If-None-Match", "X-API-Key"],
    expose_headers=["X-Total-Count", "ETag", "Content-Range"],
    max_age=3600,
)
print(browser_check(RECOMMENDED))
{'GET': '200, X-Total-Count 15, ETag "wards-2024"', 'X-API-Key': '200', 'If-None-Match': '200', 'cookies': 'blocked', 'tile, plain': 'tainted', 'tile, crossOrigin': 'readable'}

With that configuration every call from the map's page worked β€” except the one that asked to send cookies, which this configuration deliberately does not allow. Four things people miss:

  • expose_headers β€” without it, JavaScript cannot read X-Total-Count or ETag, even though they arrive.
  • If-None-Match in allow_headers β€” a script that sets it itself triggers a preflight, which fails without it.
  • max_age β€” caches the preflight, removing an OPTIONS round trip before every request.
  • allow_origins=["*"] with allow_credentials=True β€” in Starlette this does not fail: it answers every origin by name, credentials included (step 3).
Three CORS failure shapes and what each one means. tell the three apart firstsymptommeaningno OPTIONS in the network panelsimple request β€” Allow-Origin ismissingOPTIONS returns 4xxpreflight failed β€” the realrequest never wentheader visible in devtools, nullin JSnot listed inAccess-Control-Expose-Headerstile loads, canvas throwsneeds crossOrigin AND Allow-OriginThe server answered correctly in all four β€” the browser discarded the response spatialworkflow.io
This is why curl never reproduces it.

Identify the request type, reproduce it, allow the origins

1. Work out which kind of request is failing

A simple request β€” GET with no unusual headers β€” is sent, and the browser then checks for Access-Control-Allow-Origin on the response. If it is missing, the response is discarded.

A preflighted request β€” anything with a custom header such as X-API-Key, or a method other than GET/HEAD/POST β€” is preceded by an OPTIONS request. If that fails, the real request is never sent at all.

The same service under a configuration that allows the origin and nothing else shows both:

print(browser_check(dict(allow_origins=[SITE])))
{'GET': '200, X-Total-Count null, ETag null', 'X-API-Key': 'blocked', 'If-None-Match': 'blocked', 'cookies': 'blocked', 'tile, plain': 'tainted', 'tile, crossOrigin': 'readable'}

The plain GET succeeded β€” and its X-Total-Count and ETag came back null, because they were not exposed. The two requests carrying a header of their own failed at the preflight, because allow_headers was empty. In the browser's network panel, no OPTIONS entry means a simple request, and an OPTIONS that fails means the preflight is the problem.

2. Reproduce it without a browser

The browser's two requests can be replayed by hand β€” here with httpx against the service above; curl -i -H "Origin: …" does the same:

def diagnose(url, origin, method="GET", request_headers=("x-api-key", "if-none-match")):
    preflight = httpx.options(url, headers={"Origin": origin, "Access-Control-Request-Method": method,
                                            "Access-Control-Request-Headers": ",".join(request_headers)})
    simple = httpx.get(url, headers={"Origin": origin})
    allow = simple.headers.get("access-control-allow-origin")
    exposed = simple.headers.get("access-control-expose-headers", "").lower()
    allowed_headers = preflight.headers.get("access-control-allow-headers", "").lower()
    problems = []
    if not allow:
        problems.append("no Allow-Origin on the response β€” the browser will discard it")
    elif allow not in (origin, "*"):
        problems.append(f"Allow-Origin is {allow!r}, not {origin!r}")
    if allow == "*" and simple.headers.get("access-control-allow-credentials") == "true":
        problems.append("Allow-Origin * with Allow-Credentials true β€” browsers refuse credentialed requests")
    if preflight.status_code >= 400:
        problems.append(f"preflight failed with {preflight.status_code} β€” the real request is never sent")
    problems += [f"{h!r} is not in Allow-Headers" for h in request_headers if h not in allowed_headers and "*" not in allowed_headers]
    problems += [f"{h!r} is not exposed β€” JavaScript cannot read it" for h in ("etag", "x-total-count") if h not in exposed]
    return f"GET {simple.status_code}, OPTIONS {preflight.status_code}: " + ("; ".join(problems) or "no problems")

print(diagnose(f"{API['origin only']}/features", SITE))
print()
print(diagnose(f"{API['recommended']}/features", SITE))
GET 200, OPTIONS 400: preflight failed with 400 β€” the real request is never sent; 'x-api-key' is not in Allow-Headers; 'if-none-match' is not in Allow-Headers; 'etag' is not exposed β€” JavaScript cannot read it; 'x-total-count' is not exposed β€” JavaScript cannot read it

GET 200, OPTIONS 200: no problems

The first should carry Access-Control-Allow-Origin. The preflight should return 200 or 204 with Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers covering what was asked for.

3. Add the origins, not a wildcard

allow_origins=["*"] is convenient for a fully public, anonymous API. Combined with allow_credentials=True it is something else. A browser refuses a credentialed response whose Access-Control-Allow-Origin is a literal *, so Starlette does not send one β€” it copies the requesting origin into the header instead:

for origin in (SITE, "https://evil.example"):
    reply = httpx.get(f"{API['star + credentials']}/features", headers={"Origin": origin, "Cookie": "session=abc"})
    label = "<the map's origin>"
    shown = reply.headers.get("access-control-allow-origin", "").replace(SITE, label)
    print(f"{origin.replace(SITE, label):26} -> allow-origin {shown}, "
          f"allow-credentials {reply.headers.get('access-control-allow-credentials')}")
print(browser_check(dict(allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])))
<the map's origin>         -> allow-origin <the map's origin>, allow-credentials true
https://evil.example       -> allow-origin https://evil.example, allow-credentials true
{'GET': '200, X-Total-Count null, ETag null', 'X-API-Key': '200', 'If-None-Match': '200', 'cookies': '200', 'tile, plain': 'tainted', 'tile, crossOrigin': 'readable'}

Every origin, named back to itself, with credentials allowed: any site a signed-in user visits can call the API as that user and read the answer. That is the configuration to avoid. List the origins that need access, and include the development ones β€” http://localhost:5173 and friends β€” because otherwise every developer disables CORS in their browser and the problem is discovered in production.

What a page on another origin could do with a Rutland wards API under four Starlette CORS configurations, tested in Chromium. four configurations, one browserGETX-Total-CountX-API-Keycookiesno CORSβœ•blockedβ€”βœ•blockedblockedorigin onlyβœ“200!nullβœ•blockedblockedrecommendedβœ“200βœ“15βœ“200blocked* + credentialsβœ“200!nullβœ“200βœ•any siteThe last row works everywhere β€” for every other site too spatialworkflow.io
Steps 1 to 3, as the browser saw them.

4. Expose the headers your client reads

This is the one that produces the strangest bug report: "the header is in the network panel but response.headers.get() returns null" β€” exactly what step 1 showed for X-Total-Count and ETag. By default, JavaScript can read only a handful of response headers. Anything else β€” X-Total-Count, ETag, X-RateLimit-Remaining, Content-Range β€” must be listed in expose_headers.

5. Fix the tile and canvas case separately

An <img> tile loads without CORS. Reading its pixels β€” which is what a canvas-based renderer does to composite or export β€” requires the image to have been fetched with CORS:

const image = new Image();
image.crossOrigin = "anonymous";     // required before src
image.src = tileUrl;

and the tile response must carry Access-Control-Allow-Origin. Here is the service with no CORS configuration at all:

print(browser_check(None))
{'GET': 'blocked', 'X-API-Key': 'blocked', 'If-None-Match': 'blocked', 'cookies': 'blocked', 'tile, plain': 'tainted', 'tile, crossOrigin': 'no image'}

Between that and the checks above, all three cases appear: without crossOrigin the tile loaded and the canvas was tainted; with crossOrigin against a server that sends no CORS header the tile failed to load at all; with both, the pixels were readable. That usually surfaces as "the map works but export is broken".

6. Check errors, and the proxy

A response without CORS headers is discarded whatever its status, so the client sees "Failed to fetch" instead of your error message:

for path in ("/missing", "/boom"):
    reply = httpx.get(f"{API['recommended']}{path}", headers={"Origin": SITE})
    print(f"{path:9} status {reply.status_code}, allow-origin: {'present' if reply.headers.get('access-control-allow-origin') else 'MISSING'}")
/missing  status 404, allow-origin: present
/boom     status 500, allow-origin: MISSING

An HTTPException β€” the 404 β€” went through the CORS middleware and kept its headers. An unhandled exception did not: Starlette turns it into a 500 in a layer outside the middleware stack, so the browser reports a CORS failure and hides the real error. Catch exceptions into proper responses.

The same symptom comes from a proxy: an Access-Control-Allow-Origin set by the application and removed by nginx, a CDN or an API gateway looks exactly like never setting it. Run the diagnosis against the public URL, not against the application's port.

The CORS settings a spatial API needs and the ones people forget. four fields people missexpose_headersETag, X-Total-Count, X-RateLimit-*If-None-Match in allow_headersor a script that sets it fails preflightmax_agecaches the preflight round tripdevelopment origins listedlocalhost:5173 and friendsallow_credentials with allow_origins=["*"]Starlette echoes every originRead the origins from the environment so staging and production differ by config, not code spatialworkflow.io
Missing development origins is why developers disable CORS locally.

CORS read from the environment, checked against a public API

The data used on this page

Rutland's wards from the ONS December 2024 boundaries (OGL), served as GeoJSON by a small FastAPI app that also returns one PNG map tile, a 404 and an unhandled error. serve() runs an app on a local port, SITE is a second local origin standing in for the map page, and browser_check() makes the same seven requests from that page in headless Chromium.

import io
import json
import socket
import threading
import time
from pathlib import Path

import geopandas as gpd
import httpx
import matplotlib
import requests
import uvicorn
from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from playwright.sync_api import sync_playwright

matplotlib.use("Agg")
import matplotlib.pyplot as plt

Path("data").mkdir(exist_ok=True)
if not Path("data/wards.geojson").exists():
    Path("data/wards.geojson").write_bytes(requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BGC/FeatureServer/0/query",
        params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 4326, "f": "geojson"},
        timeout=120).content)
wards = gpd.read_file("data/wards.geojson")
figure, axes = plt.subplots(figsize=(2.56, 2.56), dpi=100)
wards.plot(ax=axes, color="#0ea5e9", edgecolor="white")
axes.set_axis_off()
buffer = io.BytesIO()
figure.savefig(buffer, format="png")
TILE = buffer.getvalue()


def spatial_api(cors):
    app = FastAPI()

    @app.get("/features")
    def features(response: Response):
        response.headers["X-Total-Count"] = str(len(wards))
        response.headers["ETag"] = '"wards-2024"'
        return json.loads(wards.to_json())

    @app.get("/missing")
    def missing():
        raise HTTPException(404, "no such layer")

    @app.get("/boom")
    def boom():
        raise RuntimeError("a bug in the handler")

    @app.get("/tile.png")
    def tile():
        return Response(TILE, media_type="image/png")

    if cors:
        app.add_middleware(CORSMiddleware, **cors)
    return app


def serve(app):
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
    server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="critical"))
    threading.Thread(target=server.run, daemon=True).start()
    time.sleep(1)
    return f"http://127.0.0.1:{port}"


site = FastAPI()
site.get("/")(lambda: Response("<!doctype html><title>map</title>", media_type="text/html"))
SITE = serve(site)

CHECKS = """async (base) => {
  const out = {};
  const go = async (name, path, init) => {
    try { const r = await fetch(base + path, init);
          out[name] = name === 'GET' ? `${r.status}, X-Total-Count ${r.headers.get('X-Total-Count')}, ETag ${r.headers.get('ETag')}` : String(r.status); }
    catch (e) { out[name] = 'blocked'; }
  };
  await go('GET', '/features', {});
  await go('X-API-Key', '/features', {headers: {'X-API-Key': 'k'}});
  await go('If-None-Match', '/features', {headers: {'If-None-Match': '"x"'}});
  await go('cookies', '/features', {credentials: 'include'});
  for (const cors of [false, true]) {
    const img = new Image(); if (cors) img.crossOrigin = 'anonymous';
    const loaded = await new Promise(ok => { img.onload = () => ok(true); img.onerror = () => ok(false); img.src = base + '/tile.png?' + cors; });
    let pixels = 'no image';
    if (loaded) { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
                  const g = c.getContext('2d'); g.drawImage(img, 0, 0);
                  try { g.getImageData(0, 0, 1, 1); pixels = 'readable'; } catch (e) { pixels = 'tainted'; } }
    out[cors ? 'tile, crossOrigin' : 'tile, plain'] = pixels;
  }
  return out; }"""


def browser_check(cors):
    api = serve(spatial_api(cors))
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto(SITE)
        result = page.evaluate(CHECKS, api)
        browser.close()
    return result


API = {name: serve(spatial_api(cors)) for name, cors in {
    "origin only": dict(allow_origins=[SITE]),
    "recommended": dict(allow_origins=[SITE], allow_methods=["GET", "OPTIONS"],
                        allow_headers=["Content-Type", "If-None-Match", "X-API-Key"],
                        expose_headers=["X-Total-Count", "ETag", "Content-Range"], max_age=3600),
    "star + credentials": dict(allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]),
}.items()}
print(len(wards), "wards |", len(TILE), "byte tile | site", SITE != API["recommended"] and "and API on different origins")
15 wards | 15818 byte tile | site and API on different origins

Example 1 β€” a CORS configuration read from the environment

import os

os.environ["CORS_ORIGINS"] = f"{SITE}, http://localhost:5173"
ORIGINS = [o.strip() for o in os.environ["CORS_ORIGINS"].split(",") if o.strip()]
production = dict(allow_origins=ORIGINS, allow_credentials=False,          # keys in a header, not a cookie
                  allow_methods=["GET", "HEAD", "OPTIONS"],
                  allow_headers=["Content-Type", "If-None-Match", "X-API-Key", "Authorization"],
                  expose_headers=["ETag", "X-Total-Count", "X-RateLimit-Limit", "X-RateLimit-Remaining", "Content-Range"],
                  max_age=86_400)
print([o.replace(SITE, "<the map's origin>") for o in ORIGINS])
print(browser_check(production))
["<the map's origin>", 'http://localhost:5173']
{'GET': '200, X-Total-Count 15, ETag "wards-2024"', 'X-API-Key': '200', 'If-None-Match': '200', 'cookies': 'blocked', 'tile, plain': 'tainted', 'tile, crossOrigin': 'readable'}

Reading the origins from the environment is what lets the same image run in development, staging and production without a code change β€” and stops somebody committing a wildcard because the staging origin was missing.

Example 2 β€” the same diagnostic against a real public API

print(diagnose("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
               "Wards_December_2024_Boundaries_UK_BGC/FeatureServer/0/query?where=1%3D0&f=geojson",
               "https://maps.example.org"))
GET 200, OPTIONS 200: Allow-Origin * with Allow-Credentials true β€” browsers refuse credentialed requests; 'x-api-key' is not in Allow-Headers; 'if-none-match' is not in Allow-Headers; 'etag' is not exposed β€” JavaScript cannot read it; 'x-total-count' is not exposed β€” JavaScript cannot read it

Pointed at the ONS's own ArcGIS service, which this site reads from Python every day: it allows every origin, so a browser can read it β€” but its preflight does not allow an X-API-Key header, and it sends Allow-Credentials: true beside *, which browsers ignore for anything carrying cookies.

A browser rule, not a server error

Why CORS is a browser rule and not a server error

The server answered. The browser then applied the same-origin policy: script on maps.example.org may not read a response from api.example.org unless that response says it is allowed to. That is why curl never reproduces the problem, and why the network panel shows a successful request with a JavaScript error beside it. Nothing is broken on the server; the server simply has not granted permission.

Why a preflight exists at all

A request with a custom header could, in principle, cause a side effect on a server written before CORS existed. So the browser asks first: an OPTIONS request naming the method and headers it intends to use, and the real request only follows if the server approves. Adding X-API-Key to a client therefore doubles the request count unless max_age caches the preflight, and a service that returns 405 for OPTIONS blocks every authenticated browser request.

Why exposed headers are a separate list

The default set a browser lets script read is deliberately tiny β€” Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Everything else is hidden unless exposed. For a spatial API that hides exactly the headers worth having: the total count for paging, the ETag for caching, and the rate-limit budget.

Why the canvas case is different

Loading an image cross-origin is allowed; reading its pixels is not, because that would let a page extract data it could not otherwise see. A canvas containing such an image is "tainted" and getImageData throws. The fix is both halves β€” crossOrigin="anonymous" on the client and Access-Control-Allow-Origin on the tile response β€” and either alone is insufficient.

Why a tile can load and still taint a canvas. loading and reading are different permissionsimg loadscross-originalways alloweddrawn to a canvasstill finegetImageData()throws β€” taintedcrossOrigin +Allow-Originboth halves neededSurfaces as "the map works but export is broken" spatialworkflow.io
Either half alone is insufficient.

Credentials with wildcards, origins and proxies

  • allow_credentials=True with allow_origins=["*"] is refused by browsers when a server sends a literal * β€” and Starlette avoids that by echoing every origin, which grants credentialed access to all of them.
  • Origins are scheme, host and port. http://localhost:5173 and http://localhost:3000 are different.
  • A proxy can strip the headers. Test the public URL, not the application port.
  • max_age caps out at a browser-specific limit β€” Chrome's is well under a day.
  • Error responses need CORS headers too, or the client sees a CORS error instead of your 400. In Starlette an HTTPException keeps them and an unhandled exception's 500 does not.
  • Add Vary: Origin when the allowed origin depends on the request, or a cache serves one origin's header to another.
  • Tiles for a canvas renderer need crossOrigin and the header β€” both.
  • Include development origins in the config, or developers disable CORS locally and find it in production.

FAQ

Why does my API work in curl and fail in the browser?

Because CORS is enforced by the browser, not the server. The response arrived; the browser discarded it because it lacked Access-Control-Allow-Origin.

Why is my X-Total-Count header missing in JavaScript?

It is not missing β€” it is not exposed. Browsers hide all but a handful of response headers unless the server lists them in Access-Control-Expose-Headers.

What triggers a preflight request?

A method other than GET, HEAD or POST, or any non-standard request header such as X-API-Key or If-None-Match. The real request is only sent if the OPTIONS succeeds.

Can I just use allow_origins=["*"]?

For a fully public, anonymous API, yes. Do not combine it with allow_credentials=True: Starlette then answers every origin by name with credentials allowed, so any site can make requests as your signed-in users.

Why do my tiles load but break the canvas?

Loading an image cross-origin is allowed; reading its pixels is not. Set crossOrigin="anonymous" on the client and Access-Control-Allow-Origin on the tile response.

The headers are there locally but not in production. Why?

Something in front of the application β€” nginx, a CDN, an API gateway β€” is stripping them. Test the public URL with an Origin header and compare.