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.

What each kind of tile client needs from a CORS policyThree client types compared on which CORS headers each requires: a plain z/x/y client, a PMTiles range client, and a client fetching a TileJSON document.BY CLIENT TYPEz/x/y tilesPMTiles rangesTileJSONAllow-OriginRequiredRequiredRequiredAllow-Headers: RangeNot neededRequiredNot neededExpose: Content-RangeNot neededRequiredNot neededExpose: Accept-RangesNot neededRequiredNot neededPreflight on GETNoYesNo
A policy tuned for the first column silently breaks the second. That is why a deployment can serve tiles fine and fail the moment it moves to PMTiles.

Production Configuration

For an S3-compatible bucket, including Cloudflare R2:

json
[
  {
    "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
  }
]
bash
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:

javascript
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:

bash
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.

Four CORS symptoms and what each points atA decision tree separating a failing preflight, a missing allow-origin, missing expose headers, and a cached response for the wrong origin.DIAGNOSETiles load from curl and fail in the browser —which part of the policy is wrong?OPTIONS returns noCORS headersPreflight not handled —Range is not inAllow-HeadersGET succeeds,console names CORSAllow-Origin does notmatch the page's originexactly206 arrives,PMTiles clientstill failsContent-Range is not inExpose-HeadersWorks from onemachine, fails fromanotherCached without Vary:Origin
The extra round trip a ranged request pays before its first byteThe browser sends an OPTIONS preflight naming the Range header, the origin replies with the allowed headers, and only then does the ranged GET proceed.BrowserTile originOPTIONS, Request-Headers: rangeAllow-Headers: Range, Max-Age: 86400cached for a dayGET with Range: bytes=0-16383206 + Expose-Headers: Content-Rangewithout Expose, the client is blind
With Access-Control-Max-Age set, this happens once per origin per day. Without it, before every single range request.

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.