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 present on the GET for a .pbf tile, with the response body present but greyed out and unreadable. The GET response reached the browser without the header, so WebGL discarded it. This is the base case — the origin or edge never attached the header.
  • 403 / 400 on the preflight OPTIONS. For a simple GET of a public tile the browser skips preflight, but a styled request that adds Authorization or a custom header triggers one. If the CDN or origin answers the OPTIONS with anything but a 2xx carrying Access-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: Origin or was fetched without the Origin header forwarded, so the cached copy has no per-origin header to replay.
  • 206 Partial Content with no CORS header, seen only with PMTiles or COG range reads. The archive is fetched with a Range header, and the origin or edge omits the header on 206 responses (or rejects the Range/If-Range preflight), so the byte-range reader in the browser fails while a full-file GET would 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.

Triage tree for a CORS failure on a vector tile endpoint The symptom — a browser blocked-by-CORS error on a .pbf tile, style, or sprite — branches on a single observable signal into four causes. If the GET response is missing Access-Control-Allow-Origin, the cause is header attachment at the origin or edge. If the OPTIONS preflight returns 403 or 400, the cause is the preflight handler rejecting the probe. If the request only fails cache-cold and correlates with an edge cache miss, the cause is CloudFront not forwarding the Origin header or the origin omitting Vary: Origin, so the wrong response is cached. If the response is a 206 Partial Content with no CORS header, the cause is range-request handling for PMTiles or COG byte-range reads. Blocked by CORS policy on .pbf / style / sprite Read the network-tab signal code · X-Cache · Age · Range No ACAO on GET header not attached at origin / edge 403/400 OPTIONS preflight rejected by handler Fails cold only Origin not forwarded or no Vary: Origin 206, no CORS range request PMTiles / COG Bucket CORS + response headers policy Allow OPTIONS + requested headers Forward Origin in cache key + emit Vary Allow Range + expose Accept-Ranges

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 the aws_cloudfront_response_headers_policy CORS 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, and s3:PutBucketCors on the tile bucket, scoped through the boundaries in IAM Role Mapping for Geospatial Workloads.
  • curl and 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.

  1. 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. A Range probe 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 OPTIONS must return 2xx with Access-Control-Allow-Origin, -Methods, and -Headers echoing what you sent; the range read must return 206 carrying Access-Control-Allow-Origin and Accept-Ranges: bytes. A 206 with no CORS header is the PMTiles failure exactly.

  2. Fix the bucket CORS rule so the origin emits the header at all. The S3 origin must return Access-Control-Allow-Origin on both the GET and the 206, and must expose Accept-Ranges so the byte-range reader can operate. This closes the base case and the range case at the source.

  3. Forward Origin into the cache key and emit Vary: Origin. This is the fix the base contract misses: if CloudFront does not include Origin in 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. Forwarding Origin in a cache policy and emitting Vary: Origin keys the cache per origin.

  4. Answer the preflight at the edge and cache it. Route OPTIONS so it resolves without hitting the tile backend, and set access_control_max_age_sec so a high-zoom panning session does not multiply preflight volume. The response headers policy attached to the behavior carries the allowed methods and headers.

  5. 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 curl twice. Both responses must carry Access-Control-Allow-Origin: https://app.example.com, and the second must show X-Cache: Hit from cloudfront while still carrying the header — proving the cache is keyed per origin, not replaying a wrong one.
  • Vary: Origin is present. The tile and style responses must include Vary: 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-16383 probe returns 206 with Access-Control-Allow-Origin and Accept-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 no Access-Control-Allow-Origin is 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 OPTIONS and the range probe against an ephemeral preview distribution and fails the build unless both carry the expected Access-Control-Allow-* and Vary: Origin headers. A header contract that is only checked by hand drifts within a release or two.
  • Policy-as-code on the cache key. A checkov or CrossGuard rule that fails any tile cache policy whose headers whitelist omits Origin blocks the single regression that causes cross-origin cache poisoning before merge.
  • Scheduled drift detection. A nightly terraform plan -detailed-exitcode (exit code 2 pages 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_origins and 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.