Configuring CORS Headers for Mapbox GL JS via IaC

A Mapbox GL JS map that renders perfectly on localhost but shows a blank gray canvas in production is almost always a cross-origin resource sharing (CORS) failure on the tile, style, or sprite request — the browser fetched the resource, saw no matching Access-Control-Allow-Origin header, and discarded the response before WebGL could read it. This guide walks the exact remediation for that scenario when the tile delivery stack is provisioned as code, within the broader CORS and CSP Configuration for Spatial Platforms cluster and the wider Network Security & Access Control framework. The goal is a deterministic, version-controlled header contract across the S3 origin and the CDN edge so the map renders identically from every approved origin.

Symptom identification and triage

The defining signal is a browser console error on a vector tile, style JSON, or sprite fetch:

Access to fetch at 'https://tiles.example.com/v1/{z}/{x}/{y}.pbf' from origin
'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

The network tab usually shows one of three patterns. A 200 on the GET with the response body greyed out and unreadable means the GET itself lacks Access-Control-Allow-Origin. A 403 or 400 on a preflight OPTIONS means the origin or CDN rejects the cross-origin probe outright. The most insidious pattern is a tile that loads on a warm cache hit but fails on the first request after a cache purge — the origin response is missing Vary: Origin, so the CDN cached a response keyed without the origin and replays it for the wrong caller.

Before changing any header, isolate the failing tier. Reproduce the preflight independently of the WebGL runtime so the fix targets the correct resource:

curl -sI -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  "https://tiles.example.com/v1/2/1/1.pbf"
# Expect: HTTP/2 204, Access-Control-Allow-Origin: https://app.example.com,
#         Access-Control-Allow-Methods: GET, and Vary: Origin

A clean curl but a broken browser points at credentialed requests or a CSP connect-src gap rather than CORS; a broken curl confirms the header contract itself. A 403 at this stage that is not a header problem indicates the request never reached the policy-bearing edge — that is a routing defect to reconcile against VPC Routing for Tile Servers, not a CORS fix.

CORS triage decision tree for a blank Mapbox GL JS map From the symptom "map renders blank or CORS console error", branch on the browser network signal. A 200 GET with an unreadable body and no Access-Control-Allow-Origin header routes to fixing the GET response headers via the S3 bucket CORS rule. A 403 or 400 on the OPTIONS preflight routes to fixing preflight handling at the origin or CDN. A tile that loads on a cache hit but fails on a cache miss routes to adding and forwarding Vary: Origin so the cache key includes the origin. A curl OPTIONS that is clean while the browser still fails is not a CORS issue and routes to checking credentialed requests or the CSP connect-src directive. Map renders blank / CORS console error Diagnose by browser network signal Signal A 200 GET, body unreadable no ACAO header present Signal B 403 / 400 on OPTIONS preflight Signal C OK on cache hit, fails on cache miss Signal D curl OPTIONS clean, browser still fails Fix GET response headers S3 bucket CORS rule expose ETag on GET Fix preflight handling answer OPTIONS at origin / CDN edge Add Vary: Origin forward + key cache per request origin Not a CORS fix credentialed request or CSP connect-src

Prerequisites and environment assumptions

This guide assumes an S3 origin fronted by CloudFront serving Mapbox GL JS vector tiles, styles, and sprites. Before applying:

  • Provider versions. Terraform >= 1.7.0 with the AWS provider pinned to ~> 5.40 — the aws_cloudfront_response_headers_policy CORS schema is version-sensitive. For programmatic stacks, pin @pulumi/aws to 6.30.0 in package.json.
  • Backend state with locking. Header policies are frequently touched during incidents, so a remote backend with mandatory locking is required to prevent two responders applying conflicting directives — see State Backend Selection for the locking patterns.
  • IAM permissions to apply. The executing principal needs s3:PutBucketCORS / s3:GetBucketCORS on the tile bucket plus cloudfront:CreateDistribution, cloudfront:UpdateDistribution, and cloudfront:CreateResponseHeadersPolicy. Scope these against the credential model in IAM Role Mapping for Geospatial Workloads.
  • An exact origin list. Collect the precise frontend origins (https://app.example.com, partner domains) before you start. Credentialed tile requests are incompatible with Access-Control-Allow-Origin: *, so wildcards are not an option here.

Step-by-step remediation

  1. Pin the origin list as a versioned variable. Move every allowed origin out of any console and into a variable file or parameter store entry. A new tenant origin then becomes a reviewed change, not an out-of-band edit that drift detection will later revert. The validation block rejects wildcards and malformed URLs at plan time.

  2. Apply bucket-level CORS to the S3 origin. Mapbox GL JS reads the tile body bytes, so the origin must return Access-Control-Allow-Origin on the GET itself, expose ETag for conditional revalidation, and emit it for GET, HEAD, and OPTIONS.

  3. Forward the origin headers through CloudFront. The CDN must vary on Origin, Access-Control-Request-Method, and Access-Control-Request-Headers so the cached response is keyed per origin and Vary: Origin propagates — this is the fix for the cache-hit/cache-miss failure mode. Apply the minimal configuration:

terraform {
  required_version = ">= 1.7.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.40" }
  }
}

variable "allowed_origins" {
  type        = list(string) # exact frontend/partner origins — never "*"
  validation {
    condition     = alltrue([for o in var.allowed_origins : can(regex("^https://", o))])
    error_message = "Origins must be fully qualified https URLs; wildcards break credentialed tiles."
  }
}

resource "aws_s3_bucket_cors_configuration" "tiles" {
  bucket = aws_s3_bucket.tile_assets.id
  cors_rule {
    allowed_origins = var.allowed_origins
    allowed_methods = ["GET", "HEAD"]          # OPTIONS preflight is answered by CloudFront
    allowed_headers = ["Authorization", "Content-Type", "Range"] # Range: GL JS partial reads
    expose_headers  = ["ETag", "Content-Range"]
    max_age_seconds = 86400                     # cache preflight 24h to cut OPTIONS volume
  }
}

resource "aws_cloudfront_distribution" "tile_cdn" {
  # ... origin (OAC), viewer_certificate, restrictions ...
  default_cache_behavior {
    target_origin_id       = "s3-tile-origin"
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    viewer_protocol_policy  = "redirect-to-https"
    forwarded_values {
      query_string = true
      headers      = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"]
      cookies { forward = "none" }
    }
  }
}
  1. Plan, review the diff, then apply. Run terraform plan and confirm the only changes are the CORS rule and the cache-behavior header forwarding. Apply with terraform apply, then invalidate the affected tile paths (aws cloudfront create-invalidation --paths "/v1/*") so stale, origin-blind cache entries are evicted.

For programmatic stacks the same contract is expressed in Pulumi, keeping the origin list in stack config so an invalid value fails before the cloud API is ever called:

// package.json pins: "@pulumi/pulumi": "3.110.0", "@pulumi/aws": "6.30.0"
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";

const allowedOrigins = new pulumi.Config().requireObject<string[]>("allowedOrigins");

new aws.s3.BucketCorsConfigurationV2("tileCors", {
  bucket: tileBucket.id,
  corsRules: [{
    allowedOrigins,                          // never ["*"] for credentialed tiles
    allowedMethods: ["GET", "HEAD"],
    allowedHeaders: ["Authorization", "Content-Type", "Range"],
    exposeHeaders: ["ETag", "Content-Range"],
    maxAgeSeconds: 86400,
  }],
});

Verification

Confirm the fix is live at both tiers, not just in state. Re-run the preflight probe and assert the headers are present:

curl -sI -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  "https://tiles.example.com/v1/2/1/1.pbf" | grep -iE "access-control-allow|vary"
# access-control-allow-origin: https://app.example.com
# access-control-allow-methods: GET, HEAD
# vary: Origin

Then validate the GET itself carries the origin header and Vary: Origin on a cold cache (curl -sI -H "Origin: https://app.example.com" "...3/4/4.pbf"), and confirm a disallowed origin is correctly omitted from the response — the absence of Access-Control-Allow-Origin for an unknown origin proves the allowlist is enforced rather than echoing every caller. Finally, hard-reload the Mapbox GL JS map with the network tab open: the tile, style, and sprite requests should all return 200 with readable bodies and the canvas should paint.

Preventing recurrence

Encode the fix so it cannot silently regress:

  • Policy-as-code gate. Add a checkov or tfsec rule that fails any production-tagged stack where allowed_origins contains * or allowed_headers contains *, and assert max_age_seconds <= 86400 to bound preflight cache staleness. These run pre-merge so a wildcard never reaches an apply.
  • Scheduled drift detection. A nightly terraform plan -detailed-exitcode (or pulumi preview --expect-no-changes) against production state fails the job when a console hotfix diverges from committed headers, surfacing out-of-band edits within a day instead of at the next deploy.
  • Mirror the bucket and CDN policy in one module. Keep the S3 CORS rule and the CloudFront header forwarding in a single parameterized module so the two can never drift apart — the mismatch that produces the cache-hit/cache-miss symptom. The object store that backs the tiles, covered in Object Storage for Raster and Vector Data, should consume that same module.
  • Keep the edge the only public entry point. Header enforcement at the CDN is only authoritative if the origin cannot be reached directly; the ingress rules in Security Group Hardening ensure a preflight cannot be answered by an unmanaged path.

Treating these headers as a first-class, reviewed IaC resource — rather than a CDN console toggle — is what turns an intermittent blank-map incident into a one-time, permanently guarded fix.