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.

Successful CORS Preflight and Tile Fetch Sequence An OpenLayers client and a cross-origin tile or feature endpoint complete a CORS exchange in four steps: an OPTIONS preflight carrying the Origin header; a 204 response carrying Access-Control-Allow-Origin and Access-Control-Allow-Methods; browser validation of those headers; then a GET issued with crossOrigin anonymous that returns 200 with Allow-Origin and Vary: Origin. The result is an untainted canvas where toDataURL works. Browser / OpenLayers Tile / feature endpoint OPTIONS preflight — Origin header 204 — Allow-Origin & Allow-Methods Validate CORS response headers GET tile — crossOrigin anonymous 200 — Allow-Origin + Vary: Origin Canvas untainted — toDataURL() works

Prerequisites

Before applying the configuration, confirm you have the following in place:

  • OpenLayers 7.x or 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-headers installable) or a MapProxy instance fronted by nginx/Apache.
  • The exact frontend origins that will embed the map, e.g. https://portal.example.gov and https://maps.example.gov. Wildcards are not acceptable in production.
  • TLS terminating on every participating host; protocol drift (HTTP↔HTTPS) is itself a CORS failure.
  • curl and 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.

CORS Header Injection Points Across the Portal Planes The OpenLayers client issues requests carrying an Origin header. On the tile or data plane the request hits the edge nginx or Apache layer, which is where Access-Control-Allow-Origin and Vary: Origin are injected; behind it the MapProxy cache plane is origin-agnostic and emits no CORS headers, and the edge-to-cache boundary includes Vary: Origin in its cache key. On the control plane the request reaches GeoNode/GeoServer, where the django-cors-headers middleware injects the same headers. CORS is enforced at the edge and at GeoNode, never inside MapProxy. TILE / DATA PLANE CONTROL PLANE OpenLayers client sends Origin header Edge nginx / Apache injects Allow-Origin, Allow-Methods, Vary: Origin CORS INJECTED HERE MapProxy cache plane origin-agnostic, emits no CORS headers STATELESS TILES GeoNode / GeoServer django-cors-headers middleware CORS INJECTED HERE cache key includes Vary: Origin

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

Up one level: Operational Architecture Comparison: GeoNode vs MapProxy for Geospatial Portal Scaling.