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.
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.