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 (or pulumi 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' in script-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-Origin must not contain * in any production-tagged stack.
  • CSP default-src and script-src must exclude 'unsafe-inline' and 'unsafe-eval'.
  • A report-uri or report-to directive must point at a monitored endpoint so violations are observable.
  • connect-src and img-src must enumerate the exact tile and style endpoints — never a bare scheme like https:.

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.

Where CORS and CSP Are Enforced Across the Tile-Delivery Path A browser WebGL client issues a cross-origin tile request carrying an Origin header. Enforcement point one is the CDN edge layer, which attaches the CORS response headers (Access-Control-Allow-Origin set to exact origins) and the CSP headers (connect-src and img-src), and emits Vary: Origin with origin_override so the edge policy is authoritative. Enforcement point two is the API gateway, which splits methods: OPTIONS preflights are answered immediately by a lightweight handler returning 204 plus the Access-Control-Allow headers, while GET and POST tile requests are token-validated against the IAM role mapping. Enforcement point three is the private subnet: the tile origin accepts traffic only from edge ranges via security groups, and the backing object storage carries bucket-level CORS rules that must mirror the CDN policy so cache hits and misses behave identically. Browser WebGL tile client Mapbox / MapLibre request + Origin 1 CDN / edge layer CORS headers ACAO = exact origins CSP headers connect-src · img-src Vary: Origin origin_override = true OPTIONS GET / POST 2 API gateway — method split OPTIONS handler lightweight · no auth 204 + ACA-* headers GET / POST tile token validation IAM role mapping scopes credentials validate forward on miss 3 Private subnet Tile origin SG: edge ranges only Object storage raster + vector tiles bucket CORS mirrors CDN policy bucket CORS must match edge policy (else cache-miss failure) Three enforcement points — the edge policy is only authoritative if the origin cannot be flanked solid = live request path · dashed = trust boundary / policy-mirror dependency

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:

Preflight-then-Fetch Exchange for an Authenticated Tile A sequence across three participants. Phase one is the preflight: the Browser sends an OPTIONS preflight carrying its Origin header to the CDN or API gateway, which replies 204 with the Access-Control-Allow headers. Phase two is the actual request: the Browser sends a GET tile request with Origin, the gateway forwards it to the Tile origin on a cache miss, the origin returns the tile carrying a Vary: Origin header, and the gateway returns a 200 tile response cached per origin. Solid arrows are requests; dashed arrows are responses. Browser CDN / gateway edge policy Tile origin PREFLIGHT OPTIONS preflight + Origin 204 + Access-Control-Allow-* headers ACTUAL REQUEST GET tile + Origin forward on cache miss tile + Vary: Origin 200 tile, cached per origin Vary: Origin is what keeps the per-origin cache key correct across hits and misses

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. 86400 caches the preflight result for 24 hours, which collapses repeated OPTIONS traffic 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 ensuring Vary: Origin is 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 with Access-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 without worker-src 'self' blob: (or a child-src fallback) 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 OPTIONS request 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-exitcode drift 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.