Cross-Origin Resource Sharing, kurz CORS, bleibt eine der hartnäckigsten Verwirrungsquellen der Webentwicklung. Wer versteht, was der Browser schützt und welche Header der Server zurückgeben muss, vermeidet blockierende Fehler ebenso wie gefährlich freizügige Konfigurationen.
Was der Browser wirklich schützt
Standardmäßig kann eine von einem Ursprung ausgelieferte Seite die Antwort einer an einen anderen Ursprung gesendeten Anfrage nicht lesen. Ein Ursprung besteht aus drei Teilen: Schema, Host und Port. https://app.exemple.com und https://api.exemple.com sind daher zwei verschiedene Ursprünge, ebenso wie derselbe Host über HTTP und über HTTPS. Diese Regel, die Same-Origin-Policy, hindert eine bösartige Seite daran, Daten eines Dienstes zu lesen, bei dem das Opfer angemeldet ist.
CORS schwächt diesen Schutz nicht: Es gibt dem Server ein Mittel, bestimmten Ursprüngen das Lesen seiner Antworten ausdrücklich zu erlauben. Die Anfrage geht trotzdem hinaus, aber der Browser verbirgt die Antwort vor dem JavaScript, solange die richtigen Header sie nicht freigeben. Diese Unterscheidung ist entscheidend: CORS schützt den Leser, nicht den Server, der seine eigenen Zugriffskontrollen behalten muss.
Einfache und vorab geprüfte Anfragen
Der Browser trennt zwei Fälle. Eine sogenannte einfache Anfrage (Methode GET, HEAD oder POST, einfache Header, ein Standard-Inhaltstyp) geht direkt hinaus. Sobald eine Anfrage diesen Rahmen verlässt, etwa ein PUT, ein Authorization-Header oder ein JSON-Body, sendet der Browser zuerst eine Vorabanfrage, den Preflight, mit der Methode OPTIONS. Dieser Schritt fragt den Server, ob er den Aufruf akzeptiert, bevor er ausgeführt wird.
OPTIONS /api/orders HTTP/1.1
Host: api.exemple.com
Origin: https://app.exemple.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.exemple.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: OriginSolange der Preflight keine positive Antwort erhält, wird die eigentliche Anfrage nie gesendet. Ein CORS-Fehler in der Konsole deutet fast immer auf einen Server, der dieses OPTIONS schlecht beantwortet, nicht auf einen Fehler im Client.
Die Header auf der Serverseite
Der CORS-Vertrag passt in eine Handvoll Antwort-Header. Jeden einzelnen zu kennen, klärt die meisten Blockaden.
| Header | Rolle |
|---|---|
Access-Control-Allow-Origin | erlaubter Ursprung, ein einzelner Wert oder * |
Access-Control-Allow-Methods | akzeptierte Methoden, als Antwort auf den Preflight |
Access-Control-Allow-Headers | erlaubte Anfrage-Header |
Access-Control-Allow-Credentials | erlaubt Cookies und Anmeldedaten, unvereinbar mit * |
Access-Control-Max-Age | Cache-Dauer des Preflights, in Sekunden |
Access-Control-Expose-Headers | vom JavaScript lesbare Antwort-Header |
Je nach Stack konfigurieren
Die Logik ist überall gleich: den aufrufenden Ursprung erkennen, mit einer Allow-Liste abgleichen und die passenden Header zurückgeben. Unter Node genügt eine Middleware.
const ALLOWED = new Set([
"https://app.exemple.com",
"https://admin.exemple.com",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "600");
return res.status(204).end();
}
next();
});Derselbe Vertrag kann am vorgelagerten Server liegen, was das Laden der Anwendung für bloße OPTIONS-Anfragen erspart.
location /api/ {
set $cors "";
if ($http_origin ~* ^https://(app|admin)\.exemple\.com$) {
set $cors $http_origin;
}
add_header Access-Control-Allow-Origin $cors always;
add_header Vary Origin always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
proxy_pass http://backend;
}Cookies, Anmeldedaten und klassische Fallen
Die häufigste Falle verbindet Access-Control-Allow-Origin: * mit dem Senden von Cookies. Die Spezifikation verbietet das: Sobald die Anfrage Anmeldedaten trägt, muss der Ursprung namentlich zurückgegeben und Access-Control-Allow-Credentials auf true gesetzt werden. Den empfangenen Ursprung dynamisch zu spiegeln, erfordert dann Vary: Origin, sonst könnte ein Cache einem anderen Ursprung die falsche Erlaubnis ausliefern.
Ein Stern, der alle erlaubt, und Cookies in derselben Antwort: Die Spezifikation lehnt das ab, und das ist eine gute Nachricht.
Zwei weitere Verwechslungen kehren wieder. Ein korrekter HTTP-Fehlercode mit fehlenden CORS-Headern erscheint in der Konsole als CORS-Fehler, obwohl der Server durchaus geantwortet hat. Und JavaScript darf bei einer Cross-Origin-Antwort nur die Header lesen, die in Access-Control-Expose-Headers deklariert sind, ein Detail, das oft das Lesen eines Paginierungs-Headers oder eines Tokens blockiert.
Den Header Access-Control-Allow-Origin aus dem empfangenen Ursprung zu rekonstruieren, ohne ihn gegen eine Allow-Liste zu prüfen, kommt einem Freibrief gleich. Ein zu weiter regulärer Ausdruck wie exemple.com ohne Verankerung lässt exemple.com.angreifer.net durch. Die ausdrückliche Allow-Liste bleibt der einzige sichere Weg.
Testen und debuggen ohne Raten
Das Browserverhalten auf der Kommandozeile nachzustellen, grenzt die Ursache sofort ein. Ein mit curl geformter OPTIONS-Aufruf zeigt, ob der Server die richtigen Preflight-Header zurückgibt, noch bevor der Browser geöffnet wird.
# Reproduce the preflight the browser would send
curl -i -X OPTIONS https://api.exemple.com/api/orders \
-H "Origin: https://app.exemple.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type, authorization"
# Then replay the real request and inspect its CORS headers
curl -i https://api.exemple.com/api/orders \
-H "Origin: https://app.exemple.com"Im Netzwerk-Tab der Entwicklerwerkzeuge zeigt eine von CORS blockierte Anfrage einen Status, aber eine unlesbare Antwort, während die Konsole den fehlenden Header genau benennt. Dieses Paar, eine Konsolenmeldung und dann ein curl-Rejeu, genügt, um binnen Sekunden zwischen einem Server-Konfigurationsfehler und einem echten Anwendungsfehler zu entscheiden. Ein Preflight, der in curl korrekt antwortet, aber im Browser scheitert, deutet fast immer auf einen Vermittler, einen Proxy oder ein CDN, der die Methode OPTIONS filtert oder Header umschreibt.
Subdomains, Umgebungen und Cookies
Zur Frage der Anmeldedaten kommt das SameSite-Attribut der Cookies, unabhängig von CORS und doch oft damit verwechselt. Ein Cookie mit SameSite=Lax wird bei einer Hintergrundanfrage an eine andere Seite nicht gesendet, selbst bei perfekter CORS-Konfiguration. Eine über verschiedene Subdomains geteilte Authentifizierung verlangt daher sowohl ein Cookie mit SameSite=None; Secure als auch serverseitig erlaubte Anmeldedaten. Wenn mehrere Umgebungen nebeneinander bestehen, Staging und Produktion auf benachbarten Subdomains, leitet man die Allow-Liste besser aus einer Umgebungsvariablen ab, als sie fest zu verdrahten, damit keine Staging-URL in die Produktion durchsickert.
Das Wesentliche
CORS ist kein Hindernis, das man umgeht, sondern ein Vertrag, den man einhält. Der Browser erzwingt die Same-Origin-Policy; der Server wählt bewusst, welche Ursprünge seine Antworten lesen dürfen. Eine strikte Allow-Liste, ein korrekt behandelter Preflight, ein Vary: Origin bei dynamischem Ursprung, und die allermeisten Fehler verschwinden. Die Versuchung des Platzhalters * rechtfertigt sich nur für wirklich öffentliche Ressourcen ohne Anmeldedaten. Für alles andere kostet das Benennen der Ursprünge ein paar Zeilen und schützt dauerhaft.
In meinen Projekten lautet die erste Frage, wenn eine Anfrage in Produktion bricht, nicht mehr „woher kommt dieser CORS-Fehler?“, sondern „beantwortet der Server das OPTIONS korrekt?“. In neun von zehn Fällen liegt dort die Antwort. Am Ende habe ich * aus meinen authentifizierten APIs verbannt und die Allow-Liste in einer einzigen Konstante gebündelt, die bei jedem Review erneut gelesen wird. Das ist weniger spektakulär als ein cleverer Fix, erspart mir aber, jedes Quartal dasselbe Ticket wieder zu öffnen. — Simon Janvier
Zum Weiterlesen: die CORS-Referenz der MDN Web Docs und die Fetch-Spezifikation des WHATWG.
