Fixing CORS Errors on a Spatial API
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.
Quick answer
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://maps.example.org", "http://localhost:5173"],
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,
)
Four things people miss:
expose_headersโ without it, JavaScript cannot readX-Total-CountorETag, even though they arrive.If-None-Matchinallow_headersโ otherwise conditional requests are blocked and caching stops working from the browser.max_ageโ caches the preflight, removing anOPTIONSround trip before every request.allow_credentials=Truecannot be combined withallow_origins=["*"]โ the browser rejects it.
Step-by-step solution
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 distinction shows in the browser's network panel: no OPTIONS entry means a simple request, and an OPTIONS that returns 4xx means the preflight is the problem.
2. Reproduce it without a browser
# a simple request
curl -i -H "Origin: https://maps.example.org" https://api.example.org/features
# the preflight
curl -i -X OPTIONS \
-H "Origin: https://maps.example.org" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: x-api-key,if-none-match" \
https://api.example.org/features
The first should carry Access-Control-Allow-Origin. The second 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 and it forecloses credentials: a browser will not send cookies or Authorization to a wildcard origin, and combining the two is rejected outright.
List the origins that need access. 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.
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".
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
A <img> tile loads without CORS. Reading its pixels โ which is what a canvas-based renderer does โ 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. Without both, the tile appears and the canvas is tainted, producing a security error on the first pixel read โ which usually surfaces as "the map works but export is broken".
6. Check the proxy is not stripping the headers
An Access-Control-Allow-Origin set by the application and removed by nginx, a CDN or an API gateway produces exactly the same browser error as never setting it.
curl -i -H "Origin: https://maps.example.org" https://api.example.org/features | grep -i access-control
Run that against the public URL, not against the application's port. If the header is present locally and absent publicly, the problem is in front of the application.
Code examples
Example 1 โ a CORS configuration for a public tile and feature service
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
ORIGINS = [o.strip() for o in os.environ.get(
"CORS_ORIGINS",
"https://maps.example.org,http://localhost:5173").split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=ORIGINS,
allow_credentials=False, # keys go 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,
)
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 โ a diagnostic that tells you which layer is wrong
import httpx
def diagnose_cors(url, origin, method="GET",
request_headers=("x-api-key", "if-none-match")):
print(f"origin {origin} โ {url}\n")
preflight = httpx.options(url, headers={
"Origin": origin,
"Access-Control-Request-Method": method,
"Access-Control-Request-Headers": ",".join(request_headers)})
print(f"preflight OPTIONS โ {preflight.status_code}")
for header in ("access-control-allow-origin", "access-control-allow-methods",
"access-control-allow-headers", "access-control-max-age"):
value = preflight.headers.get(header)
print(f" {header:32} {value or 'MISSING'}")
simple = httpx.get(url, headers={"Origin": origin})
print(f"\nactual GET โ {simple.status_code}")
allow = simple.headers.get("access-control-allow-origin")
print(f" access-control-allow-origin {allow or 'MISSING'}")
exposed = simple.headers.get("access-control-expose-headers", "")
print(f" access-control-expose-headers {exposed or 'MISSING'}")
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}, which does not match "
f"{origin!r}")
if preflight.status_code >= 400:
problems.append(f"preflight failed with {preflight.status_code} โ "
f"the real request is never sent")
for header in request_headers:
allowed = preflight.headers.get("access-control-allow-headers", "").lower()
if header not in allowed and "*" not in allowed:
problems.append(f"{header!r} is not in Allow-Headers")
for header in ("etag", "x-total-count"):
if header not in exposed.lower():
problems.append(f"{header!r} is not exposed โ JavaScript cannot read it")
print()
for problem in problems:
print(" !", problem)
return problems
Example 3 โ a browser-side check for the canvas case
// Tiles load fine as images and taint the canvas unless both sides cooperate.
async function checkTileCors(tileUrl) {
const image = new Image();
image.crossOrigin = "anonymous";
const loaded = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error("tile failed to load with CORS"));
});
image.src = tileUrl;
await loaded;
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
canvas.getContext("2d").drawImage(image, 0, 0);
try {
canvas.getContext("2d").getImageData(0, 0, 1, 1);
return "ok: the tile can be read from a canvas";
} catch {
return "tainted: the tile server needs Access-Control-Allow-Origin";
}
}
Explanation
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 GET 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.
The practical consequences: adding X-API-Key to a client 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-Type, Expires, Last-Modified, Pragma. Everything else is hidden unless exposed, so that a server cannot leak information to script by accident.
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. The symptom โ visible in the network panel, null in JavaScript โ is confusing enough that it is worth setting expose_headers before anybody asks.
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.
Canvas-based map renderers hit this whenever they composite or export tiles. The fix is both halves โ crossOrigin="anonymous" on the client and Access-Control-Allow-Origin on the tile response โ and either alone is insufficient.
Edge cases or notes
allow_credentials=Truewithallow_origins=["*"]is rejected by browsers.- Origins are scheme, host and port.
http://localhost:5173andhttp://localhost:3000are different. - A proxy can strip the headers. Test the public URL, not the application port.
max_agecaps 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.
- Add
Vary: Originwhen the allowed origin depends on the request, or a cache serves one origin's header to another. - Tiles for a canvas renderer need
crossOriginand the header โ both. - Include development origins in the config, or developers disable CORS locally and find it in production.
Internal links
- Serving spatial data explained: files, features and tiles โ the service being consumed
- How to test a spatial API with pytest and httpx โ the preflight test
- Fixing a tile endpoint that returns 404 or blank tiles โ the other reason tiles do not appear
- Fixing blank or grey tiles in an embedded map โ the client-side symptom
- Authentication and rate limits for a spatial API โ why the custom header triggers a preflight
- How to set cache headers on a spatial API and tile service โ
Varyand caches - Fixing a web map layer that does not appear โ the wider diagnosis
- How to build a GeoJSON API with FastAPI โ where the middleware goes
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 API, yes โ but it forecloses credentials: a browser will not send cookies or Authorization to a wildcard origin, and combining the two is rejected.
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.