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.
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.0with the AWS provider pinned to~> 5.40— theaws_cloudfront_response_headers_policyCORS schema is version-sensitive. For programmatic stacks, pin@pulumi/awsto6.30.0inpackage.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:GetBucketCORSon the tile bucket pluscloudfront:CreateDistribution,cloudfront:UpdateDistribution, andcloudfront: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 withAccess-Control-Allow-Origin: *, so wildcards are not an option here.
Step-by-step remediation
-
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
validationblock rejects wildcards and malformed URLs at plan time. -
Apply bucket-level CORS to the S3 origin. Mapbox GL JS reads the tile body bytes, so the origin must return
Access-Control-Allow-Originon theGETitself, exposeETagfor conditional revalidation, and emit it forGET,HEAD, andOPTIONS. -
Forward the origin headers through CloudFront. The CDN must vary on
Origin,Access-Control-Request-Method, andAccess-Control-Request-Headersso the cached response is keyed per origin andVary: Originpropagates — 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" }
}
}
}
- Plan, review the diff, then apply. Run
terraform planand confirm the only changes are the CORS rule and the cache-behavior header forwarding. Apply withterraform 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
checkovortfsecrule that fails any production-tagged stack whereallowed_originscontains*orallowed_headerscontains*, and assertmax_age_seconds <= 86400to bound preflight cache staleness. These run pre-merge so a wildcard never reaches an apply. - Scheduled drift detection. A nightly
terraform plan -detailed-exitcode(orpulumi 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.
Related
- CORS and CSP Configuration for Spatial Platforms — the parent cluster covering header policy as code
- Network Security & Access Control — the parent domain this guide sits within
- VPC Routing for Tile Servers — the private data-plane routing preflight traffic must respect
- Pulumi IAM Policies for S3 Raster Access — the credential-side incident-response companion to this CORS walkthrough