CORS and CSP Configuration for Spatial Platforms
Cross-Origin Resource Sharing (CORS) and Content Security Policy (CSP) are the browser-enforced security boundaries that decide whether a map ever renders: a single missing Access-Control-Allow-Origin header silently breaks WebGL tile fetches, while a permissive default-src opens a spatial portal to data exfiltration. These headers must be managed as declarative, version-controlled resources within the broader Network Security & Access Control framework rather than hand-edited in a CDN console, because manual edits drift away from the committed state and reappear as intermittent, hard-to-reproduce rendering failures. Header policy also has to stay synchronized with the private data plane defined in VPC Routing for Tile Servers and the credential scoping established by IAM Role Mapping for Geospatial Workloads, so the three boundaries enforce one coherent contract instead of three independent ones.
Geospatial clients make this configuration unusually unforgiving. WebGL and canvas renderers — Mapbox GL JS, MapLibre, OpenLayers, Cesium — issue thousands of high-frequency cross-origin requests per session against tile servers, WMS/WFS endpoints, and geocoding APIs, and every one of them is gated by the same headers. Browsers enforce the same-origin policy described in the W3C Cross-Origin Resource Sharing specification, while CSP constrains script, style, image, and connection sources per the MDN Content Security Policy reference. Codifying both in Terraform or Pulumi turns these directives into reviewable, testable artifacts that promote cleanly from development to production.
Environment parity and configuration drift mitigation
The fastest way to ship a CORS or CSP defect is to maintain three hand-tuned header sets — one per environment — that diverge over time. The durable pattern is a single parameterized module where the only inputs that change between development, staging, and production are the allowed origin list, the tile endpoint list, and the violation-report endpoint. Directive structure (default-src, connect-src, img-src, the allowed methods, the Vary: Origin behavior) stays byte-for-byte identical across environments so that a policy validated in staging behaves identically in production.
Drift on these resources is especially corrosive because it is invisible until a specific origin or zoom level is exercised. An operator who whitelists a partner origin directly in the CloudFront or Cloudflare console produces a state file that no longer matches reality; the next apply either reverts the fix (breaking the partner) or, worse, the drift is masked because the resource is marked create_before_destroy. Mitigation is procedural and codified:
- Single source of truth. Allowed origins live in a versioned variable file or parameter store entry, never in a console. A new tenant origin is a pull request, not a click.
- Scheduled drift detection. A nightly
terraform plan -detailed-exitcode(orpulumi preview --expect-no-changes) fails the job when the live header policy diverges from committed state, surfacing out-of-band edits within 24 hours. - Remote state locking. CSP and CORS policies are frequently edited during incident response; concurrent applies without a state lock can interleave directive updates and leave a half-written policy live. Locking is non-negotiable — see State Backend Selection for the backend patterns that enforce it.
- Wildcard prohibition by environment. Production stacks must reject
Access-Control-Allow-Origin: *and any'unsafe-inline'/'unsafe-eval'inscript-src; staging may relax these only behind an explicit flag.
CI/CD validation and operational guardrails
Header misconfigurations are cheap to catch in a pull request and expensive to debug in production, so policy-as-code gates belong in the pre-merge pipeline. Static analyzers (checkov, tfsec, or pulumi-policy/CrossGuard) parse the rendered configuration and fail the build on the constraints that matter for spatial delivery:
Access-Control-Allow-Originmust not contain*in any production-tagged stack.- CSP
default-srcandscript-srcmust exclude'unsafe-inline'and'unsafe-eval'. - A
report-uriorreport-todirective must point at a monitored endpoint so violations are observable. connect-srcandimg-srcmust enumerate the exact tile and style endpoints — never a bare scheme likehttps:.
Beyond static checks, the pipeline should run a live preflight assertion against an ephemeral preview environment. A scripted curl -I -X OPTIONS against a representative tile path confirms the 204 preflight response carries the expected Access-Control-Allow-* headers and a Vary: Origin value before the change is allowed to promote. New CSP directives should land in Content-Security-Policy-Report-Only mode first: deploy report-only to staging for 48–72 hours, collect violations from real client traffic, reconcile false positives, and only then flip to enforcing. This staged rollout is the single most effective guardrail against a CSP change that blocks a legitimate basemap or font CDN.
The same incident-response depth that exists for sibling resources applies here; the worked Mapbox example in Configuring CORS Headers for Mapbox GL JS via IaC shows how connect-src and img-src are aligned with vector tile endpoints under a real failure.
Resource architecture and service integration
CORS and CSP headers are applied at the edge, but they only behave correctly when the layers behind them agree. Header enforcement at the CDN or API gateway must complement the subnet isolation and route tables described in VPC Routing for Tile Servers: if a preflight OPTIONS request can reach a backend through a path the security layer does not cover, the header contract is bypassed entirely. Authenticated tile delivery adds a second integration point — preflight requests must resolve without triggering credential validation, so the gateway routes OPTIONS to a lightweight handler that returns headers immediately while GET/POST requests validate tokens against the IAM Role Mapping for Geospatial Workloads configuration. This separation prevents a CORS failure from masquerading as an authentication error during triage.
Ingress hardening closes the loop. The tile origin’s security groups should accept traffic only from CDN edge ranges or the gateway, a pattern detailed in Security Group Hardening, so the header policy at the edge is the only public entry point and cannot be flanked by a direct origin hit. The object storage that backs raster and vector tiles — see Object Storage for Raster and Vector Data — carries its own bucket-level CORS rules that must mirror the CDN policy; a mismatch produces the classic symptom of a tile that loads on a cache hit but fails on a cache miss because the origin response lacks Vary: Origin.
Runnable configuration
The configuration below provisions a CloudFront response headers policy that carries both the CORS rules and the CSP, then attaches it to the distribution’s default cache behavior. The CSP is assembled from a directive map so the same structure renders identically in every environment; only var.allowed_origins, var.tile_endpoints, and var.csp_report_endpoint differ. Note the pinned provider — every spatial header change should be reproducible against a known provider version.
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40" # pin: response_headers_policy CORS schema is version-sensitive
}
}
}
variable "environment" { type = string }
variable "allowed_origins" { type = list(string) } # exact tenant/agency origins, never "*"
variable "tile_endpoints" { type = list(string) } # vector/raster tile + style hosts
variable "csp_report_endpoint" { type = string }
locals {
# Directive structure is identical across environments; only the inputs vary.
csp_directives = {
"default-src" = "'self'"
"connect-src" = join(" ", concat(["'self'"], var.tile_endpoints))
"img-src" = join(" ", concat(["'self'", "data:"], var.tile_endpoints))
"script-src" = "'self'" # no 'unsafe-inline'/'unsafe-eval' — blocked by policy gate
"style-src" = "'self' 'unsafe-inline'" # GL JS injects inline canvas styles
"worker-src" = "'self' blob:" # MapLibre/Mapbox spin up web workers from blobs
"report-uri" = var.csp_report_endpoint
}
}
resource "aws_cloudfront_response_headers_policy" "cors_csp" {
name = "spatial-platform-cors-csp-${var.environment}"
cors_config {
access_control_allow_credentials = false # true only with a single explicit origin
access_control_allow_origins { items = var.allowed_origins }
access_control_allow_methods { items = ["GET", "OPTIONS"] }
access_control_allow_headers { items = ["Authorization", "Content-Type"] }
access_control_max_age_sec = 86400 # cache preflight 24h to cut OPTIONS volume
origin_override = true # CDN policy wins over origin headers
}
security_headers_config {
content_security_policy {
content_security_policy = join("; ", [for k, v in local.csp_directives : "${k} ${v}"])
override = true
}
strict_transport_security {
access_control_max_age_sec = 63072000
include_subdomains = true
preload = true
override = true
}
}
}
resource "aws_cloudfront_distribution" "spatial_cdn" {
# ... origin, restrictions, and viewer_certificate config ...
default_cache_behavior {
# ... target_origin_id, viewer_protocol_policy, allowed_methods ...
response_headers_policy_id = aws_cloudfront_response_headers_policy.cors_csp.id
}
}
For teams standardizing on programmatic IaC, the equivalent Pulumi resource keeps the directive map in TypeScript and pins both SDK packages in package.json so the rendered policy is deterministic.
// package.json pins: "@pulumi/pulumi": "3.110.0", "@pulumi/aws": "6.30.0"
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const config = new pulumi.Config();
const allowedOrigins = config.requireObject<string[]>("allowedOrigins"); // never ["*"] in prod
const tileEndpoints = config.requireObject<string[]>("tileEndpoints");
const csp = [
`default-src 'self'`,
`connect-src 'self' ${tileEndpoints.join(" ")}`,
`img-src 'self' data: ${tileEndpoints.join(" ")}`,
`script-src 'self'`,
`style-src 'self' 'unsafe-inline'`,
`worker-src 'self' blob:`,
`report-uri ${config.require("cspReportEndpoint")}`,
].join("; ");
const headers = new aws.cloudfront.ResponseHeadersPolicy("spatialCorsCsp", {
name: `spatial-platform-cors-csp-${pulumi.getStack()}`,
corsConfig: {
accessControlAllowCredentials: false,
accessControlAllowOrigins: { items: allowedOrigins },
accessControlAllowMethods: { items: ["GET", "OPTIONS"] },
accessControlAllowHeaders: { items: ["Authorization", "Content-Type"] },
accessControlMaxAgeSec: 86400,
originOverride: true,
},
securityHeadersConfig: {
contentSecurityPolicy: { contentSecurityPolicy: csp, override: true },
},
});
When the policy is correct, the browser preflight resolves before the tile request proceeds. The exchange for an authenticated tile fetch is:
Guardrails embedded in configuration
Several guardrails are encoded directly in the resource above rather than left to operator discipline. State locking matters disproportionately for header policies because they are touched during incidents; a remote backend with locking prevents two responders from applying conflicting directive updates and leaving a partially written CSP live. No secrets in CSP/CORS — these directives are public by nature, but the report-uri endpoint and any token-validating gateway behind it must keep their credentials in a secret manager, never inlined into the header string or committed to state.
Network isolation is the precondition, not a layer that can be skipped. origin_override = true ensures the CDN policy is authoritative, but that only holds if the origin cannot be reached except through the CDN; the security group rules from Security Group Hardening enforce that. Preflight caching is a sizing decision, not a default to ignore.
Sizing note —
access_control_max_age_sec. This value is seconds, an integer, not a percentage or duration string.86400caches the preflight result for 24 hours, which collapses repeatedOPTIONStraffic from a busy map session into a single round trip. Set it too low and a high-zoom panning session multiplies preflight volume against the gateway; set it beyond the browser cap (Chromium clamps to 7200s, Firefox to 86400s) and the surplus is silently ignored. Pick the value deliberately per environment and document it alongside the origin list.
Finally, immutability has a cost. Both Terraform and Pulumi treat a response headers policy as a resource whose in-place edits can trigger replacement, briefly desynchronizing edge caches. For zero-downtime directive changes, use versioned policy names (...-cors-csp-${var.environment}-v2) and cut the distribution over to the new policy id, retiring the old one after caches drain — a header-level analogue of the blue-green approach used for cost-sensitive infrastructure tracked through Cost Estimation Frameworks.
Troubleshooting and failure modes
- Cache-hit/cache-miss origin mismatch. A tile loads on repeat requests but fails the first time after a cache purge. The origin response lacks
Vary: Origin(or the bucket CORS rules diverge from the CDN policy), so the CDN caches a response for the wrong origin. Fix by mirroring the bucket-level CORS to the CDN policy and ensuringVary: Originis emitted on every origin response. - Wildcard plus credentials rejection. The browser refuses an authenticated tile fetch with “credentials flag is true, but Access-Control-Allow-Origin is
*.” A wildcard origin is incompatible withAccess-Control-Allow-Credentials: true; the policy must echo the single requesting origin. This is the most common production CORS failure for token-protected tiles. - CSP blocks the worker or blob URL. MapLibre and Mapbox GL JS instantiate web workers from
blob:URLs; a CSP withoutworker-src 'self' blob:(or achild-srcfallback) throws a console error and the map never paints. Add the directive and re-test in report-only mode before enforcing. - Preflight bypasses the security layer. An
OPTIONSrequest reaches a backend through an uncovered route, returning headers from the wrong tier. This is a routing defect, not a header defect — reconcile the route tables in VPC Routing for Tile Servers so every cross-origin path traverses the policy-bearing edge. - Silent drift after a console hotfix. A partner origin works for a day, then breaks after the next deploy reverts the manual console edit. The scheduled
plan -detailed-exitcodedrift check exists precisely to surface this within a day; the durable fix is to add the origin to the versioned variable and apply through the pipeline.
Related
- Network Security & Access Control — the parent domain this configuration sits within
- VPC Routing for Tile Servers — private data-plane routing that preflight traffic must respect
- IAM Role Mapping for Geospatial Workloads — credential scoping for authenticated tile access
- Security Group Hardening — ingress rules that keep the CDN the only public entry point
- Configuring CORS Headers for Mapbox GL JS via IaC — a worked incident-response walkthrough for this exact resource