Managing Cross-Domain CORS for OpenLayers Clients
A step-by-step procedure for negotiating Cross-Origin Resource Sharing (CORS) between a browser-based OpenLayers map and tile, feature, and metadata endpoints that live on separate domains.
This page sits under the GeoNode vs MapProxy Architecture Comparison and the broader Core Portal Architecture & Security Boundaries guide; read those first if you are still deciding where tile caching and metadata services terminate, because the split between a stateless cache plane and a stateful control plane dictates where you enforce CORS.
In a decoupled portal, frontend assets are served from one origin while tile caches, WFS/WMS endpoints, and authentication gateways answer from others. The browser’s same-origin policy forces every one of those reads to be explicitly authorized by the responding server. When that negotiation is missing or inconsistent, OpenLayers surfaces it as opaque network errors, silently dropped tiles, preflight OPTIONS rejections, or a tainted canvas that breaks toDataURL() on export. The steps below fix each layer of that exchange.
The sequence below shows a successful cross-origin exchange — the browser’s preflight, the server’s CORS headers, and the actual tile fetch that keeps the canvas untainted.
Prerequisites
Before applying the configuration, confirm you have the following in place:
- OpenLayers
7.xor newer in the frontend bundle, with source instantiation under your control (not behind an opaque widget wrapper). - Administrative access to the backend that answers tiles and features — either a GeoNode Django deployment (
django-cors-headersinstallable) or a MapProxy instance fronted by nginx/Apache. - The exact frontend origins that will embed the map, e.g.
https://portal.example.govandhttps://maps.example.gov. Wildcards are not acceptable in production. - TLS terminating on every participating host; protocol drift (HTTP↔HTTPS) is itself a CORS failure.
curland browser developer tools for verification, plus write access to the CI pipeline that gates deployments.
Step-by-Step Implementation
1. Declare crossOrigin on every OpenLayers source
OpenLayers issues a no-CORS request by default, which strips response headers and taints the canvas. Set crossOrigin explicitly on raster sources (ol/source/TileWMS, ol/source/ImageWMS, ol/source/XYZ) and rely on the standard fetch path for vector sources.
import TileWMS from 'ol/source/TileWMS.js';
import XYZ from 'ol/source/XYZ.js';
// Anonymous: public tiles, no cookies or auth headers sent cross-origin.
const wmsSource = new TileWMS({
url: 'https://tiles.portal.example.gov/geoserver/wms',
params: { LAYERS: 'agency:parcels', TILED: true },
crossOrigin: 'anonymous',
});
const baseLayer = new XYZ({
url: 'https://cache.portal.example.gov/tiles/{z}/{x}/{y}.png',
crossOrigin: 'anonymous',
});
For endpoints that require a session cookie or mutual TLS, switch to 'use-credentials' so the browser attaches credentials and expects a reflected origin in return:
const securedSource = new TileWMS({
url: 'https://secure.portal.example.gov/geoserver/wms',
params: { LAYERS: 'agency:restricted' },
crossOrigin: 'use-credentials',
});
The OpenLayers maintainers document this canvas-tainting behaviour directly in the OpenLayers API reference — omitting crossOrigin makes getImageData() and toDataURL() throw a SecurityError the moment a styled or exported layer touches the canvas.
2. Emit CORS headers from GeoNode (Django middleware)
When the control plane is GeoNode, let django-cors-headers answer the preflight rather than hand-rolling header logic in a view. Install the middleware ahead of CommonMiddleware so OPTIONS is resolved before routing.
# settings.py (GeoNode / Django control plane)
INSTALLED_APPS += ["corsheaders"]
MIDDLEWARE = ["corsheaders.middleware.CorsMiddleware"] + MIDDLEWARE
# Strict allowlist — never CORS_ALLOW_ALL_ORIGINS in production.
CORS_ALLOWED_ORIGINS = [
"https://portal.example.gov",
"https://maps.example.gov",
]
CORS_ALLOW_CREDENTIALS = True # required for session-authenticated WFS
CORS_PREFLIGHT_MAX_AGE = 300 # conservative until headers are stable
CORS_ALLOW_HEADERS = ["authorization", "x-api-key", "x-requested-with"]
With CORS_ALLOW_CREDENTIALS = True, the middleware echoes the specific requesting origin instead of * and adds Vary: Origin, which is exactly the combination the WHATWG Fetch Standard requires when credentials are in play.
3. Add CORS at the proxy in front of MapProxy
MapProxy itself does not synthesize CORS headers — it is a stateless tile plane, so the standard pattern is to terminate CORS at the nginx (or Apache) layer that already fronts it. This also keeps the cache origin-agnostic, matching the data-plane / control-plane split described in the architecture comparison and in Fallback Routing Strategies for Tile Servers.
# /etc/nginx/conf.d/tiles.conf — CORS for the MapProxy tile plane
map $http_origin $cors_origin {
default "";
"~^https://portal\.example\.gov$" $http_origin;
"~^https://maps\.example\.gov$" $http_origin;
}
server {
listen 443 ssl;
server_name tiles.portal.example.gov;
location / {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, X-Api-Key" always;
add_header Access-Control-Max-Age 300 always;
add_header Content-Length 0;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
proxy_pass http://mapproxy_upstream;
}
}
The map block reflects only allowlisted origins and emits an empty header for everything else, so an unapproved domain receives no Access-Control-Allow-Origin at all and the browser blocks the read.
4. Make the Origin header part of the cache key
Reverse proxies and CDNs will happily cache the first Access-Control-Allow-Origin value and replay it to a different domain, producing intermittent failures that never reproduce locally. Include Origin in the cache key and never cache the OPTIONS response.
# Within the caching tier in front of the tile plane
proxy_cache_key "$scheme$host$request_uri$http_origin";
location = /healthz { } # health probe, no CORS needed
location / {
proxy_no_cache $request_method = OPTIONS;
proxy_cache_bypass $request_method = OPTIONS;
}
5. Scope credentials and OGC service boundaries
Credentialed CORS widens the blast radius of a misconfiguration, so pair it with the access-control model. Restrict which OGC operations cross the boundary as described in Security Boundary Mapping for OGC Services, and bind allowlisted origins to tenant scope using the patterns in Implementing RBAC for Multi-Tenant GIS Portals. Keep the origin allowlist in infrastructure-as-code rather than hardcoded in application config, so a new agency domain is reviewed through the same pipeline as any other change.
Why Vary: Origin decides whether the cache is safe
The single most damaging CORS mistake on a tiled portal is not a missing header — it is a correct header that gets cached against the wrong key. When the edge reflects the requesting origin into Access-Control-Allow-Origin, that response body is identical for every caller but its headers are not. A cache that keys only on the URL will happily store the response produced for https://atlas.agency.gov and replay it, headers and all, to a browser on https://public.example.org. The second browser then sees an Allow-Origin naming somebody else’s site, refuses the response, and the map fails in a way that is invisible server-side: the tile was served with HTTP 200 and the access log shows nothing wrong.
Vary: Origin fixes this by making the origin part of the cache key, so each allowed origin gets its own stored variant. The cost is real and worth stating plainly — the cache now holds one copy per origin per tile — which is why the allowlist should be short and why the tile plane behind the edge should stay origin-agnostic rather than emitting CORS headers of its own.
If the portal serves a single public map and genuinely allows any origin, prefer a static Access-Control-Allow-Origin: * over reflection: it needs no Vary, caches as one object, and cannot leak an origin name. Reflection with Vary is the right answer only when the allowlist is real — when credentials, private layers, or per-tenant styling mean the set of permitted callers is smaller than “everyone”.
Verification
Confirm each layer before wiring it into the live map. Inspect the preflight first, then the actual request.
# 1. Preflight: expect 204 with Allow-Origin echoing the requested origin.
curl -i -X OPTIONS \
-H "Origin: https://portal.example.gov" \
-H "Access-Control-Request-Method: GET" \
https://tiles.portal.example.gov/tiles/1.0.0/agency/webmercator/0/0/0.png
# 2. Actual GET: expect 200, Access-Control-Allow-Origin, and Vary: Origin.
curl -i \
-H "Origin: https://portal.example.gov" \
"https://tiles.portal.example.gov/geoserver/wms?SERVICE=WMS&REQUEST=GetCapabilities"
# 3. Negative test: a non-allowlisted origin must NOT receive Allow-Origin.
curl -i \
-H "Origin: https://evil.example.com" \
"https://tiles.portal.example.gov/geoserver/wms?SERVICE=WMS&REQUEST=GetCapabilities"
In the browser, open the Network tab and check that the OPTIONS row returns 204 and the tile row returns 200 with the expected headers. Finally, prove the canvas is untainted by calling map.getTargetElement().querySelector('canvas').toDataURL() from the console — a clean data URL means CORS succeeded end to end. Promote these three curl checks into a CI gate so a regression in any deployment target fails the pipeline before it reaches production.
Troubleshooting Matrix
| Symptom | Likely cause | Fix |
|---|---|---|
Preflight returns 405/404 |
OPTIONS reaches auth or routing before a CORS handler |
Resolve OPTIONS at the edge (Step 3) or place CorsMiddleware ahead of CommonMiddleware (Step 2) |
| Request aborts after preflight | Access-Control-Allow-Headers omits a custom header like X-Api-Key |
Add every custom request header to the allow list on both OPTIONS and the actual response |
Tiles render but toDataURL() throws SecurityError |
crossOrigin not set on the OpenLayers source |
Set crossOrigin: 'anonymous' (or 'use-credentials') on the source (Step 1) |
| Works for one domain, fails for another | Proxy/CDN cached the first Allow-Origin value |
Add Origin to proxy_cache_key and skip caching OPTIONS (Step 4) |
| Browser rejects credentialed response | Allow-Origin: * returned with Allow-Credentials: true |
Reflect the specific origin and include Vary: Origin (Steps 2–3) |
| Intermittent silent blocks | Protocol or subdomain drift between assets and endpoints | Align TLS and host resolution across frontend, proxy, and tile endpoints |
Related
- GeoNode vs MapProxy Architecture Comparison — where the cache and control planes terminate, and therefore where CORS is enforced.
- Security Boundary Mapping for OGC Services — restricting which WMS/WFS/WCS operations may cross the origin boundary.
- Fallback Routing Strategies for Tile Servers — keeping the tile plane origin-agnostic behind a routing layer.
Up one level: Operational Architecture Comparison: GeoNode vs MapProxy for Geospatial Portal Scaling.