CORS Configuration for Cross-Origin Tile Requests
A tile client fetching from another origin needs more than Access-Control-Allow-Origin. A range-reading PMTiles client also needs Range in the allowed request headers and Content-Range in the exposed response headers — and a policy missing either produces a failure that looks identical to a network error.
When to Use This
Whenever the map’s origin differs from the tiles’ origin, which is nearly always in production and nearly never in development. That asymmetry is why CORS problems are a staging-environment specialty: locally everything is served from one host and no policy is exercised.
Specification Detail
| Header | Direction | Needed for | Consequence when missing |
|---|---|---|---|
Access-Control-Allow-Origin |
Response | Everything | The fetch fails entirely |
Access-Control-Allow-Methods |
Response | Preflighted requests | OPTIONS fails, then the fetch |
Access-Control-Allow-Headers: Range |
Response | Range reads | Preflight fails before any byte moves |
Access-Control-Expose-Headers: Content-Range |
Response | Range reads | The client reads the body but cannot learn the object length |
Access-Control-Expose-Headers: Accept-Ranges |
Response | Range reads | The client cannot confirm ranges are supported |
Access-Control-Max-Age |
Response | Preflight caching | A preflight before every request |
The two Expose-Headers entries are the ones people miss. Without them the browser receives the headers and refuses to show them to JavaScript, so a PMTiles client that fetched bytes successfully cannot work out where in the archive it is — and reports a failure that mentions nothing about CORS.
Production Configuration
For an S3-compatible bucket, including Cloudflare R2:
[
{
"AllowedOrigins": [
"https://maps.example.com",
"https://staging.maps.example.com"
],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "If-Match", "If-None-Match"],
"ExposeHeaders": [
"Content-Range",
"Content-Length",
"Accept-Ranges",
"ETag"
],
"MaxAgeSeconds": 86400
}
]
aws s3api put-bucket-cors --bucket tiles --cors-configuration file://cors.json \
--endpoint-url "https://$ACCOUNT.r2.cloudflarestorage.com"
And the equivalent from a Worker or a server, where the policy is a response header rather than bucket configuration:
const CORS = {
"Access-Control-Allow-Origin": "https://maps.example.com",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Range",
"Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges, ETag",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
};
The Vary: Origin is required whenever the allowed origin is anything but *, or a CDN will cache the response for one origin and serve it to another — which fails for the second origin and is maddening to reproduce, because it depends on which edge the reader lands on.
Testing Without a Browser
Two curl invocations replicate what the browser does, and they are far faster to iterate on than a page reload:
ORIGIN=https://maps.example.com
URL=https://tiles.example.com/v43/basemap.pmtiles
# 1. The preflight the browser sends before a ranged GET
curl -sI -X OPTIONS "$URL" \
-H "Origin: $ORIGIN" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: range" \
| grep -i "^access-control"
# access-control-allow-origin: https://maps.example.com
# access-control-allow-methods: GET, HEAD
# access-control-allow-headers: Range
# access-control-max-age: 86400
# 2. The actual ranged request
curl -sI -r 0-99 "$URL" -H "Origin: $ORIGIN" \
| grep -iE "^(http|access-control|content-range|accept-ranges)"
# HTTP/2 206
# access-control-allow-origin: https://maps.example.com
# access-control-expose-headers: Content-Range, Content-Length, Accept-Ranges, ETag
# content-range: bytes 0-99/4194304000
# accept-ranges: bytes
If the first command returns nothing, the preflight is failing and no tile will ever load. If the second returns 206 but no expose-headers line, the fetch will succeed and the PMTiles client will still fail — which is the confusing case this test exists to catch.
Interaction Effects
With caching. A response cached without Vary: Origin under a non-wildcard policy is served to the wrong origin from that edge. Either use * — appropriate for a genuinely public tileset — or set Vary: Origin and accept a cache entry per origin.
With the sprite and glyph bundles. They are cross-origin fetches too, and they live under a different prefix that bucket-level policies frequently miss. The symptom is a map with tiles and no icons — see debugging missing icons and fonts.
With a Worker in front. A Worker’s own response headers supersede the bucket’s for anything it serves, which is one of the better reasons to put one in the path — the policy becomes code rather than bucket configuration.
With preflight cost. A ranged GET is preflighted, so without Access-Control-Max-Age the browser sends an OPTIONS before every request. Setting it to a day removes that entirely after the first.
Performance Impact
The policy itself costs nothing. The preflight does: an extra round trip before the first range read, which on a cold map is directly visible in time to first tile. A 24-hour Max-Age reduces it to one preflight per origin per day per browser.
Wildcard versus specific origin has a caching consequence rather than a latency one — * allows one cache entry shared by everyone, while a specific origin with Vary means one entry per origin. For a public basemap the wildcard is both simpler and cheaper.
Common Mistakes
Omitting Range from Allow-Headers. The preflight fails and no bytes ever move. This is the most common PMTiles CORS failure.
Omitting Content-Range from Expose-Headers. Bytes arrive, the client cannot orient itself, and the error mentions nothing about CORS.
Forgetting Vary: Origin. Produces failures that depend on which edge a reader hits.
Listing an origin without its port in development. http://localhost:5173 and http://localhost are different origins.
Applying the policy to the tiles prefix only. Sprites, glyphs and the TileJSON document all need it too.
FAQ
Is Access-Control-Allow-Origin: * safe for tiles?
For a public tileset, yes — the data is public and the wildcard avoids both Vary and per-origin cache fragmentation. It is inappropriate only when the tiles are restricted, in which case CORS is not the control anyway.
Why does it work in development?
Because the page and the tiles are served from the same origin, so no CORS check happens at all. This is why CORS problems are almost always discovered in staging.
Do plain z/x/y tiles need Expose-Headers?
No. A simple GET with no Range header is not preflighted and the client does not need to read response headers. Only range-reading clients do.
Can a CDN add these headers?
Yes, and it is often easier than bucket configuration — particularly for varying the policy by path prefix, which most bucket CORS implementations cannot express.
Related
- PMTiles Range-Request Delivery — the parent topic and the full delivery requirement list.
- Configuring R2 and S3 for PMTiles Range Requests — the bucket settings this policy sits beside.
- Debugging HTTP 416 Range-Request Failures — the other failure that looks like a CORS problem and is not.
- Debugging Missing Icons and Fonts in MapLibre — the same policy applied to the asset bundles.