Debugging CORS Failures on Vector Tile Endpoints
A MapLibre or Mapbox GL JS map that renders on localhost but throws has been blocked by CORS policy the moment it fetches .pbf vector tiles, a style.json, or a sprite from production is the failure this guide dissects — and it walks you past the obvious fix to the ones that survive it: a CloudFront distribution silently dropping the Origin header, an edge cache replaying a response that carries the wrong Access-Control-Allow-Origin, and 206 Partial Content range requests to a PMTiles archive that never see a CORS header at all. It sits within CORS and CSP Configuration for Spatial Platforms and the wider Network Security & Access Control framework, and it assumes the header contract is provisioned as code so the fix is reproducible rather than a console hotfix that drifts back.
Symptom identification and triage
A CORS failure on a tile endpoint is a browser-side verdict, not a server error, so the tile server logs usually show a clean 200 while the map stays blank. The diagnostic signal lives in the browser console and the network tab, and it takes one of four distinct shapes. Read the shape before you touch a header — each maps to a different tier, and three of the four are cache- or edge-layer defects that a bucket CORS rule alone will never fix.
No 'Access-Control-Allow-Origin' header is presenton theGETfor a.pbftile, with the response body present but greyed out and unreadable. TheGETresponse reached the browser without the header, so WebGL discarded it. This is the base case — the origin or edge never attached the header.403/400on the preflightOPTIONS. For a simpleGETof a public tile the browser skips preflight, but a styled request that addsAuthorizationor a custom header triggers one. If the CDN or origin answers theOPTIONSwith anything but a2xxcarryingAccess-Control-Allow-*, the real request never fires.- Loads on a warm cache, fails cold. The first request after a cache purge fails; a refresh succeeds. The edge cached an origin response that either lacked
Vary: Originor was fetched without theOriginheader forwarded, so the cached copy has no per-origin header to replay. 206 Partial Contentwith no CORS header, seen only with PMTiles or COG range reads. The archive is fetched with aRangeheader, and the origin or edge omits the header on206responses (or rejects theRange/If-Rangepreflight), so the byte-range reader in the browser fails while a full-fileGETwould have worked.
The single most common false lead is treating a CloudFront-cached failure as an origin bug. Reproduce the request twice — once cache-cold, once warm — and inspect X-Cache and Age on the response. A failure that only appears on X-Cache: Hit from cloudfront is a cache-key or forwarded-header defect at the edge, not a missing rule on the bucket.
Prerequisites and environment assumptions
This guide assumes vector tiles or a PMTiles archive stored in Amazon S3, fronted by a CloudFront distribution, and consumed by a browser GL renderer. To reproduce and fix the failure you need:
- Terraform 1.7+ with the AWS provider pinned to
~> 5.40, because theaws_cloudfront_response_headers_policyCORS schema and the cache-policy header-forwarding arguments are version-sensitive; an unpinned provider is a leading cause of a plan that renders a different policy than the one reviewed. - A single source of truth for allowed origins. The tenant and agency origins live in a versioned variable, never a console entry, so the same list promotes from staging to production — the parity discipline the parent CORS and CSP Configuration for Spatial Platforms guide requires.
- IAM permissions to edit the distribution and the bucket CORS:
cloudfront:UpdateDistribution,cloudfront:CreateResponseHeadersPolicy,cloudfront:CreateCachePolicy, ands3:PutBucketCorson the tile bucket, scoped through the boundaries in IAM Role Mapping for Geospatial Workloads. curland browser devtools to reproduce the preflight independently of the WebGL runtime. Isolating the header exchange from the renderer is what keeps the fix targeted at the right resource.
The base header contract for this stack is established in Configuring CORS Headers for Mapbox GL JS via IaC; this page picks up where that leaves off, when the base contract is in place and the failure persists at the edge.
Step-by-step remediation
Reproduce first, then fix the tier the reproduction implicates. Applying a bucket CORS rule when the real defect is a CloudFront cache key wastes a deploy and leaves the failure live.
-
Reproduce the preflight and the range read out of band. Fire the exact requests the renderer makes, with an explicit
Origin, so the response headers are visible without WebGL swallowing them. ARangeprobe surfaces the PMTiles-specific case.# Preflight for a tile fetched with Authorization curl -sI -X OPTIONS 'https://tiles.example.com/v1/8/61/95.pbf' \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Headers: authorization' # Range read against a PMTiles archive — must echo CORS on the 206 curl -sI 'https://tiles.example.com/basemap.pmtiles' \ -H 'Origin: https://app.example.com' \ -H 'Range: bytes=0-16383'The
OPTIONSmust return2xxwithAccess-Control-Allow-Origin,-Methods, and-Headersechoing what you sent; the range read must return206carryingAccess-Control-Allow-OriginandAccept-Ranges: bytes. A206with no CORS header is the PMTiles failure exactly. -
Fix the bucket CORS rule so the origin emits the header at all. The S3 origin must return
Access-Control-Allow-Originon both theGETand the206, and must exposeAccept-Rangesso the byte-range reader can operate. This closes the base case and the range case at the source. -
Forward
Origininto the cache key and emitVary: Origin. This is the fix the base contract misses: if CloudFront does not includeOriginin the cache key, it caches the first caller’s response — header and all — and replays it to every other origin, so a purge makes the map fail for a caller whose origin was not the one that populated the cache. ForwardingOriginin a cache policy and emittingVary: Originkeys the cache per origin. -
Answer the preflight at the edge and cache it. Route
OPTIONSso it resolves without hitting the tile backend, and setaccess_control_max_age_secso a high-zoom panning session does not multiply preflight volume. The response headers policy attached to the behavior carries the allowed methods and headers. -
Invalidate the poisoned cache and re-request cold. After applying, invalidate the affected paths so the wrongly-keyed responses are evicted, then re-run the cold reproduction from step 1 to confirm the header is present on
X-Cache: Miss.
The minimal, reviewable Terraform that carries the two edge-layer fixes — forwarding Origin in the cache key and echoing the CORS headers — is a cache policy plus a response headers policy attached to the tile behavior:
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40" # cache-policy header forwarding + CORS schema are version-sensitive
}
}
}
variable "allowed_origins" { type = list(string) } # exact origins, never ["*"] with tiles
# Cache key MUST include Origin, or the edge replays one caller's ACAO to everyone.
resource "aws_cloudfront_cache_policy" "vector_tiles" {
name = "vector-tile-cache-origin-keyed"
default_ttl = 86400
max_ttl = 604800
min_ttl = 0
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
cookies_config { cookie_behavior = "none" }
query_strings_config { query_string_behavior = "none" }
headers_config {
header_behavior = "whitelist"
headers { items = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"] }
}
}
}
resource "aws_cloudfront_response_headers_policy" "vector_tiles" {
name = "vector-tile-cors"
cors_config {
access_control_allow_credentials = false
access_control_allow_origins { items = var.allowed_origins }
access_control_allow_methods { items = ["GET", "OPTIONS", "HEAD"] } # HEAD/Range for PMTiles
access_control_allow_headers { items = ["Authorization", "Range", "If-Range"] }
access_control_expose_headers { items = ["Accept-Ranges", "Content-Range", "Content-Length"] }
access_control_max_age_sec = 86400 # cache preflight 24h; collapses OPTIONS on a panning session
origin_override = true # edge header wins over any stale origin header
}
}
Pair it with the matching S3 bucket CORS so the origin emits the header on the GET and the 206:
resource "aws_s3_bucket_cors_configuration" "tiles" {
bucket = aws_s3_bucket.tiles.id
cors_rule {
allowed_methods = ["GET", "HEAD"]
allowed_origins = var.allowed_origins # mirror the CDN list exactly
allowed_headers = ["Range", "If-Range", "Authorization"]
expose_headers = ["Accept-Ranges", "Content-Range", "Content-Length", "ETag"]
max_age_seconds = 3600
}
}
Verification
Confirm the fix at the edge, not just at the origin, because the whole failure class is an edge-caching one.
- Cold and warm agree. Re-run the step-1
curltwice. Both responses must carryAccess-Control-Allow-Origin: https://app.example.com, and the second must showX-Cache: Hit from cloudfrontwhile still carrying the header — proving the cache is keyed per origin, not replaying a wrong one. Vary: Originis present. The tile and style responses must includeVary: Origin; its absence is the cache-poisoning root cause and its presence is the proof of the fix.- The range read carries CORS. The
Range: bytes=0-16383probe returns206withAccess-Control-Allow-OriginandAccept-Ranges: bytes, so PMTiles and COG readers succeed. - A second origin is isolated. Repeat the probe with
-H 'Origin: https://evil.example.com'(an origin not in the list) and confirm noAccess-Control-Allow-Originis returned — the scope is tight, not merely open. - The map paints. Reload the GL map cache-cold (hard refresh) from an approved origin and confirm tiles, style, and sprite all load with no console error.
Preventing recurrence
Encode the fix so the same edge defect cannot silently return:
- Live preflight assertion in CI. Add a pipeline step that runs the step-1
curl -I -X OPTIONSand the range probe against an ephemeral preview distribution and fails the build unless both carry the expectedAccess-Control-Allow-*andVary: Originheaders. A header contract that is only checked by hand drifts within a release or two. - Policy-as-code on the cache key. A
checkovor CrossGuard rule that fails any tile cache policy whoseheaderswhitelist omitsOriginblocks the single regression that causes cross-origin cache poisoning before merge. - Scheduled drift detection. A nightly
terraform plan -detailed-exitcode(exit code2pages the owning team) surfaces an out-of-band console edit to the response headers policy within a day, the same discipline applied to Scheduled drift detection for tile-server fleets. - Mirror bucket CORS to the CDN list. Keep the S3
allowed_originsand the CloudFront allowed origins driven by the same variable, so a new tenant origin is one pull request and the cache-hit / cache-miss mismatch never reappears.
Frequently asked questions
Tiles load on refresh but fail on the first request after a deploy — why?
The edge cached an origin response that has no per-origin Access-Control-Allow-Origin, either because CloudFront did not forward Origin into the cache key or the origin omitted Vary: Origin. Add Origin to the cache policy header whitelist, emit Vary: Origin, and invalidate the affected paths so the poisoned entries are evicted.
My `.pbf` tiles work but a PMTiles basemap fails with a 206 error — what changed?
PMTiles is read with HTTP Range requests, and the origin or edge is omitting Access-Control-Allow-Origin on the 206 Partial Content response or rejecting the Range/If-Range preflight. Allow Range and If-Range in the CORS headers, expose Accept-Ranges and Content-Range, and include HEAD in the allowed methods.
Can I just set `Access-Control-Allow-Origin: *` to make it go away?
Only for fully public, unauthenticated tiles — and even then it breaks the moment a request carries credentials, because a wildcard origin is incompatible with Access-Control-Allow-Credentials: true. For token-protected tiles the edge must echo the single requesting origin from an explicit allow-list, which also requires Origin in the cache key.
The bucket CORS looks correct but the browser still blocks the tile — where else do I look?
At CloudFront. origin_override = true on the response headers policy means the edge attaches the header regardless of the origin, but if Origin is not in the cache key the edge still serves a wrongly-keyed cached copy. Check X-Cache and Age: a failure only on Hit from cloudfront is an edge cache-key defect, not a bucket rule.
Related
- CORS and CSP Configuration for Spatial Platforms — the parent guide on the edge header contract this page debugs.
- Configuring CORS Headers for Mapbox GL JS via IaC — the base remediation this page extends into the edge-cache layer.
- Network Security & Access Control — the framework these browser-level boundaries sit within.
- Scheduled Drift Detection for Tile-Server Fleets — catching an out-of-band header edit before it breaks a caller.