1. Problem: Origin, Preflight und OPTIONS
Wenn Sie jemals in der Browser-Konsole so etwas wie „Blocked by CORS policy“ gesehen haben, dann sind Sie bereits auf eine Situation gestoßen, in der Ihr Backend möglicherweise sogar ehrlich eine Antwort zurückgegeben hat – aber der Browser sagte: „Benutzer, tut mir leid, ich zeige das deiner Seite nicht.“ Der häufigste Auslöser dafür ist eine Preflight-Anfrage, genau diese OPTIONS-Anfrage, die vor der „echten“ PATCH/POST/DELETE-Anfrage eintrifft.
Das Origin-Bild haben wir bereits festgehalten: In unserer lokalen Baseline lebt die Seite auf http://localhost:5173 und die API auf http://localhost:8080, daher wendet der Browser die Cross-Origin-Regeln an. Jetzt ist die folgende Frage wichtig: Warum erreicht die eigentliche PATCH- oder DELETE-Anfrage manchmal nicht einmal den Controller – und warum beginnt der Browser überhaupt mit einer separaten OPTIONS-Anfrage?
Stellen wir uns das lebendigste Szenario aus unserer Realität vor: Das Frontend läuft auf http://localhost:5173 und unsere Secure Content Platform API auf http://localhost:8080. Der Host ist derselbe (localhost), aber der Port ist unterschiedlich, also ist es für den Browser Cross-Origin. Und nun versucht die Seite, das Profil zu aktualisieren:
Endpoint: PATCH /api/me/profile
Body: JSON
Header: Content-Type: application/json
plus X-CSRF-TOKEN: ... (weil wir Stateful + CSRF haben)
Was macht der Browser? Er sendet oft zuerst eine „Erkundungsanfrage“ (Preflight), etwa so:
# Preflight-Anfrage, die der Browser vor der „echten“ PATCH sendet
OPTIONS /api/me/profile HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, x-csrf-token
Und jetzt ein wichtiges Detail: Wenn der Server auf diese OPTIONS-Anfrage nicht das antwortet, was das CORS-Modell erwartet, sendet der Browser nicht die eigentliche PATCH-Anfrage. In den DevTools sehen Sie, dass die Anfrage „nicht durchgeht“, aber in Wirklichkeit wurde sie noch nicht einmal in ihrer „echten“ Form gesendet – sie wurde auf der Stufe „darf man das so machen?“ gestoppt.
Ein typischer Fehler sieht so aus: Spring Security kann standardmäßig entscheiden, dass OPTIONS /api/me/profile eine „irgendwie verdächtige Anfrage ohne Authentifizierung“ ist, und antwortet mit 401/403/Redirect zur Login-Seite. Dem Browser hilft das nicht: Der Preflight ist fehlgeschlagen, also bekommt JavaScript keinen Zugriff auf die Antwort, und Sie sehen einen CORS-Fehler, selbst wenn der Server „im Grunde lebt“.
Damit der Preflight erfolgreich ist, muss der Server eine verständliche CORS-Antwort zurückgeben, in der klar gesagt wird: „Ja, für diesen Origin ist es erlaubt, mit diesen Methoden, mit diesen Headern, und ja – Cookies sind auch erlaubt (falls Sie sie verwenden).“ Vereinfacht sieht das so aus:
# Antwort auf Preflight: Diese Header prüft der Browser, bevor er die eigentliche Anfrage sendet
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE
Access-Control-Allow-Headers: content-type,x-csrf-token
Access-Control-Allow-Credentials: true
Und erst danach sendet der Browser die tatsächliche Anfrage.
2. Mentales Modell von CORS: Origins, Methods, Headers
Wenn man CORS zum ersten Mal konfiguriert, ist man versucht, es als „Berechtigungstabelle auf dem Server“ zu betrachten. Aber richtiger ist es, CORS als Verhandlung zwischen Browser und Server zu sehen. Der Browser stellt Fragen („Darf ich das so machen?“), der Server antwortet („Darf / Darf nicht“), und der Browser entscheidet, ob er dem JavaScript-Code Zugriff auf die Antwort gewährt. Der Server „lässt“ die Anfrage nicht in das System herein – er teilt nur die Regeln mit, die Durchsetzung übernimmt der Browser.
Der bequemste Weg, um nicht durcheinanderzukommen, ist, zwei Gruppen von Headern im Kopf zu behalten.
Die erste Gruppe – das, was der Browser in die Anfrage legt:
Origin – wer ist der Initiator (von wo kommt die Seite)
Access-Control-Request-Method – welche Methode die Seite ausführen möchte
Access-Control-Request-Headers – welche Header die Seite senden möchte
Die zweite Gruppe – das, was der Server in der Antwort zurückgibt:
Access-Control-Allow-Origin – für welchen Origin wir den Zugriff auf die Antwort erlauben
Access-Control-Allow-Methods – welche Methoden wir erlauben
Access-Control-Allow-Headers – welche Request-Header wir erlauben zu senden
Access-Control-Allow-Credentials – ob Cookies/Credentials gesendet werden dürfen
Access-Control-Max-Age – wie lange der Browser das Preflight-Ergebnis cachen darf
Es ist sehr nützlich, die Entsprechung „Einstellung in Spring“ ↔ „Header in HTTP“ zu sehen. Fassen wir das in einer kleinen Tabelle zusammen (das ist eine dieser seltenen Tabellen, die wirklich Nerven sparen):
| Was wir in Spring konfigurieren (CorsConfiguration) | Was der Browser in der Antwort erhält | Wofür das nötig ist |
|---|---|---|
| allowedOrigins | Access-Control-Allow-Origin | Bestimmte Origins erlauben |
| allowedMethods | Access-Control-Allow-Methods | Methoden erlauben (PATCH, DELETE usw.) |
| allowedHeaders | Access-Control-Allow-Headers | Nicht standardmäßige/benötigte Header erlauben (z.B. X-CSRF-TOKEN) |
| allowCredentials | Access-Control-Allow-Credentials | Senden von Cookies/Credentials bei Cross-Origin erlauben |
| maxAge | Access-Control-Max-Age | Preflight cachen, damit nicht alle 5 Sekunden ein OPTIONS gesendet wird |
| exposedHeaders | Access-Control-Expose-Headers | JS erlauben, bestimmte Response-Header zu lesen |
Jetzt zwei wichtige praktische Punkte, die einem Anfänger normalerweise nicht erklärt werden und die er dann „mit der Stirn findet“.
Erster Punkt: JSON führt fast immer zu Preflight. Selbst wenn Sie keine „benutzerdefinierten“ Header senden, gilt Content-Type: application/json im CORS-Modell nicht als „einfacher“ Content-Type. Das bedeutet, eine normale REST-Anfrage mit JSON-Body im Browser ist fast garantiert ein OPTIONS davor. Und wenn Sie zusätzlich X-CSRF-TOKEN hinzufügen, verstärken Sie diese Garantie. Daher ist Preflight kein seltener „Edge Case“, sondern Ihre tägliche Realität.
Zweiter Punkt: Credentials im CORS sind nicht „irgendwelche Logins“, sondern ganz konkret darum, ob der Browser Cookies und andere Credentials an eine Cross-Origin-Anfrage anhängen darf. Für unser Stateful-Modell ist das kritisch, weil JSESSIONID im Cookie lebt. Wenn Credentials verboten sind, können Sie sich noch so oft anmelden, der Browser sendet das Session-Cookie nicht bei Cross-Origin-Anfragen – und das Backend sieht Sie als anonymous.
Und hier gibt es eine wichtige Sicherheitsregel: Wenn Sie allowCredentials(true) aktiviert haben, dann dürfen Sie nicht mit Access-Control-Allow-Origin: * antworten. Der Browser akzeptiert das nicht. Und dem Sinn nach ist es gefährlich: „Ich erlaube jedem Origin, meine Cookies zu senden“ – das klingt wie ein Witz, der schlecht endet. Daher sind für session-basiertes CORS fast immer explizite Origins notwendig.
3. CORS in Spring Security: Filter und Reihenfolge
Wenn wir sagen „CORS in Spring Security konfigurieren“, klingt das, als ob wir eine weitere Regel „vor dem Controller“ hinzufügen. In der Praxis ist CORS jedoch ein separates Stück Infrastruktur, das ganz am Anfang der Filterkette arbeiten muss, sonst läuft der Preflight gegen Authentifizierung/Autorisierung, und der Browser kommt gar nicht zur „echten“ Anfrage. Einfach gesagt: Erst verhandeln wir mit dem Browser „darf man“, und dann kümmern wir uns darum „wer bist du“ und „was darfst du“.
Im Servlet-Stack von Spring wird CORS normalerweise über CorsFilter implementiert. Seine Idee ist einfach: Beim Eingehen einer Anfrage betrachtet er den Origin und (falls nötig) die Preflight-Header, sucht eine passende Konfiguration und fügt die benötigten Access-Control-* Header zur Antwort hinzu. Und wenn es sich um einen Preflight handelt (OPTIONS mit den entsprechenden Headern), kann er die Anfrage „vorzeitig“ beenden, ohne dass sie den DispatcherServlet erreicht.
In Spring Security aktivieren Sie die CORS-Unterstützung in der SecurityFilterChain über http.cors(...). In diesem Moment fügt Spring Security die CORS-Verarbeitung in die Filterkette ein, damit sie an der richtigen Stelle arbeitet und nicht mit der übrigen Security-Mechanik kollidiert.
Schematisch sieht das gute Bild so aus:
sequenceDiagram
participant B as Browser
participant S as "Spring Security (filter chain)"
B->>S: "OPTIONS /api/me/profile (preflight)"
S-->>B: 200 + Access-Control-*
B->>S: "PATCH /api/me/profile (cookie + CSRF)"
S-->>B: 200 (normale Antwort des Controllers)
Aus diesem Schema ergibt sich eine praktische Regel: Wenn Sie einen CorsConfigurationSource haben, aber nicht .cors(...) in der SecurityFilterChain aktiviert haben, dann wird Ihr OPTIONS mit hoher Wahrscheinlichkeit weiter durch die Security-Filter laufen und dort entweder ein 401/403 erhalten oder ohne die nötigen CORS-Header zurückkommen. Und der Browser sagt darauf: „Danke, aber nein.“
Ein weiterer Punkt, den man im Hinterkopf behalten sollte: CORS in Spring kann an mehreren Stellen konfiguriert werden. Man kann @CrossOrigin über die Controller verteilen, eine globale Konfiguration über Spring MVC hinzufügen oder einen einheitlichen CorsConfigurationSource erstellen und ihn an die Security anbinden. Für ein Lernprojekt und für ein sicheres Denken ist die letzte Strategie sinnvoller: eine einzige Quelle der Wahrheit, lesbare Konfiguration und minimale Überraschungen.
4. Implementierung: CORS-Konfiguration in der API
Jetzt machen wir das, weswegen wir eigentlich hier sind: Wir fügen unserem Projekt eine verständliche CORS-Konfiguration hinzu, die auf einen separaten Browser-Origin ausgelegt ist (z.B. den Dev-Server des Frontends). Wir streben nach zwei Dingen gleichzeitig: dass es ohne Zauberei funktioniert und dass es nicht wie „alles für alle für immer erlauben“ aussieht (weil das meist der Grund ist, warum es später weh tut).
Nehmen wir dasselbe lokale Szenario http://localhost:5173 -> http://localhost:8080 und bauen wir eine CORS-Richtlinie, die später nur noch um Cookie und /csrf ergänzt werden muss, anstatt alles neu zu schreiben.
Einstellungen in application.yml
Beginnen wir damit, dass Origins eine Umgebungskonfiguration sind. Heute testen Sie mit http://localhost:5173, morgen ist es ein anderer Browser-Origin. Daher lagern wir sie in die application.yml aus:
app:
security:
cors:
allowed-origins:
# In unserer aktuellen lokalen Baseline reicht ein Browser-Origin
- "http://localhost:5173"
Beachten Sie zwei Kleinigkeiten: Es gibt keine Pfade (/api), keine nachgestellten Schrägstriche, und der Port ist explizit angegeben (falls er nicht Standard ist). Das ist genau der Origin, wie wir in der letzten Vorlesung vereinbart haben.
Properties-Objekt (kurz und bündig)
Wir erstellen einen kleinen record für die Properties, um nicht @Value durch das ganze Projekt zu schleppen:
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
@ConfigurationProperties(prefix = "app.security.cors")
// Liste der Origins, die Antworten der API aus dem Browser lesen dürfen
public record CorsProps(List<String> allowedOrigins) { }
Und binden ihn in den Kontext ein (in einer separaten Konfigurationsklasse, um die SecurityConfig nicht zu einem riesigen Monstrum zu machen):
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(CorsProps.class) // Einstellungen aus application.yml übernehmen
class SecurityPropsConfig { }
Bauen des CorsConfigurationSource
Jetzt erstellen wir einen Bean CorsConfigurationSource, der die Hauptfrage beantwortet: „Welche CORS-Regeln gelten für welche Pfade?“ Für ein Lernprojekt ist es logisch, CORS auf /api/** zu setzen – dort lebt unsere API.
import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource(CorsProps props) {
CorsConfiguration cfg = new CorsConfiguration();
// Wer von welchem Browser aus auf die API zugreifen kann (wichtig: es ist genau der Origin, ohne Pfad)
cfg.setAllowedOrigins(props.allowedOrigins());
// Welche Methoden wir für Cross-Origin-Anfragen erlauben
cfg.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
// Welche Header der Client senden darf (sonst wird Preflight vom Browser abgelehnt)
cfg.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
// Cookies (z.B. JSESSIONID) in Cross-Origin-Anfragen erlauben
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// Diese CORS-Regeln nur auf API-Routen anwenden
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
Hier ist alles bewusst „eng“: konkrete Origins, konkrete Methoden, konkrete Header. Ja, man kann * setzen, aber das ist wie „die Verkabelung reparieren, indem man die Sicherung entfernt“ – die Glühbirne leuchtet, aber das Leben wird unruhig.
Speziell zu allowCredentials(true): Im Stateful-Modell ist das fast obligatorisch, sonst nimmt das Session-Cookie einfach nicht an Cross-Origin-Anfragen teil.
Wenn Sie das „Rauschen“ durch ständige Preflight-Anfragen etwas reduzieren möchten, können Sie das Preflight-Caching hinzufügen (ohne Übertreibung):
cfg.setMaxAge(1800L); // 30 Minuten, Wert in Sekunden
CORS mit der SecurityFilterChain verbinden und OPTIONS erlauben
Jetzt das Wichtigste: Wir fügen .cors(...) in die Kette ein und lassen den Preflight explizit durch, damit er nicht gegen „authentifiziere dich zuerst“ läuft.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.CorsUtils;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
CorsConfigurationSource corsSource) throws Exception {
// Wichtig: CORS muss in die SecurityFilterChain eingebunden werden, sonst läuft Preflight gegen Security-Filter
http.cors(cors -> cors.configurationSource(corsSource));
http.authorizeHttpRequests(auth -> auth
// Preflight ist kein „Geschäfts-Request“, es ist normalerweise sicher, ihn durchzulassen
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
Das Erlauben von Preflight ruft oft inneren Protest hervor: „Wie kann man das einfach freigeben?“ Aber Preflight gibt an sich keinen Zugriff auf Ihre Daten; es erlaubt dem Browser nur zu entscheiden, ob die Seite eine echte Anfrage stellen darf. Der eigentliche PATCH /api/me/profile bleibt weiterhin unter authenticated() und unter CSRF.
Schnelltest mit curl (ohne Frontend)
Auch ohne Frontend zu schreiben, können Sie den Preflight manuell testen, um nicht mehr „nach der Browser-Konsole zu raten“.
curl -i -X OPTIONS "http://localhost:8080/api/me/profile" \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: PATCH" \
-H "Access-Control-Request-Headers: content-type,x-csrf-token"
In der Antwort möchten Sie zumindest Access-Control-Allow-Origin, Access-Control-Allow-Methods und Access-Control-Allow-Headers sehen. Fehlen sie, wird der Browser nicht glücklich sein, selbst wenn Ihr Controller perfekt ist und Sie aus der IDE anlächelt.
5. Anti-Patterns bei CORS
Wenn CORS endlich funktioniert, kommt die Versuchung, „den Erfolg zu zementieren“ und alles komplett zu öffnen. Das ist menschlich verständlich: Man möchte, dass der Tab Network grün ist, anstatt dass man sich durch einen Wust von Headern liest. Aber technisch ist CORS ein Ort, an dem man besser langweilig und vorsichtig sein sollte. Ein langweiliges Config ist ein Kompliment an Ihre Zukunft.
Der typisch gefährliche Trick ist, allowedOrigins(*), allowedMethods(*), allowedHeaders(*) zu setzen und sich zu beruhigen. Das ist nicht „repariert“, sondern „ich höre auf, die Temperatur zu messen, um die Krankheit nicht zu sehen“. Im Session-basierten Modell werden Sie außerdem feststellen, dass allowCredentials(true) mit * kollidiert, sodass der „universelle Stern“ oft nicht einmal technisch funktioniert.
Ein weiteres Anti-Pattern ist, @CrossOrigin über die Controller zu verteilen. Irgendwann vergessen Sie, dass in einem Controller ein Origin erlaubt ist, in einem anderen ein anderer, und Sie fangen an, „es funktioniert, aber manchmal“ zu erleben (die teuerste Art von Bug). Für ein Lernprojekt ist eine einzige Quelle der Wahrheit besser: CorsConfigurationSource, registriert auf den benötigten Pfaden.
Es ist auch wichtig, CORS nicht mit echtem API-Schutz zu verwechseln. CORS verbietet einem Angreifer (oder einfach einem beliebigen Skript auf dem Server) nicht, Ihren Endpoint über curl/requests/HttpClient aufzurufen. CORS schränkt nur ein, welcher JavaScript-Code im Browser die Antwort lesen kann. Daher darf man Sicherheit nicht so desig nen, dass „ich habe CORS konfiguriert, also können andere meine Daten nicht abgreifen“. Sie können es doch. Nur nicht aus dem Browser, sondern vom Server. Der echte Schutz bleibt in der SecurityFilterChain, den Rollen und den Zugriffsregeln.
Und schließlich eine schlechte Angewohnheit, die man sanft, aber bestimmt abstellen möchte: „PATCH funktioniert nicht – ich schalte CSRF aus.“ Sehr oft funktioniert PATCH nicht, weil der Preflight aufgrund der CORS-Einstellungen fehlgeschlagen ist (oder der benötigte Header nicht erlaubt ist), und CSRF hat damit gar nichts zu tun. Wenn Sie CSRF ausschalten, machen Sie die Anfrage manchmal „zufällig“ einfach (oder ändern das Client-Verhalten), aber Sie beheben nicht die Ursache. Sie tauschen nur die Symptome – wie jemand, der die „Check Engine“-Leuchte mit Isolierband überklebt.
6. Typische Fehler bei der Arbeit mit CORS
CORS-Fehler sind tückisch, weil sie oft gleich aussehen: „CORS policy blocked…“. Aber die Ursachen können völlig unterschiedlich sein, und die richtige Angewohnheit ist, sofort zu prüfen, an welcher Stelle es genau hakt: Preflight nicht durchgekommen, Header nicht erlaubt, Credentials nicht aktiviert, oder Sie haben vergessen, CORS in die Filterkette einzubinden.
Fehler Nr. 1: CorsConfigurationSource ist vorhanden, aber .cors(...) in der SecurityFilterChain ist nicht aktiviert.
Das passiert oft, wenn die Konfiguration zwar „geschrieben“, aber nicht „verbunden“ wurde. In der Folge erwarten Sie, dass Spring Access-Control-Allow-* liefert, aber der Preflight läuft weiter durch die Security-Filter und prallt gegen authenticated() oder einen Redirect. Heilung ist einfach: CORS ist Teil der Kette, also muss es explizit in die Chain eingebunden werden.
Fehler Nr. 2: allowedOrigins erlaubt, aber statt des Origins eine URL mit Pfad angegeben.
http://localhost:3000/api ist kein Origin, und der Browser wird das nicht „wie beabsichtigt“ vergleichen. Er vergleicht nur Schema, Host und Port. Wenn Sie einen Pfad hinzugefügt haben, haben Sie nur eine Zeichenkette geschrieben, die nie übereinstimmen wird. Gleiches gilt für nachgestellte Schrägstriche und „fast gleiche“ Ports.
Fehler Nr. 3: allowCredentials(true) aktiviert und gleichzeitig Origin * erlaubt.
Das ist ein Klassiker. Im besten Fall erhalten Sie ungültige CORS-Header, die der Browser ablehnt. In strengeren Konfigurationen verweigert Spring möglicherweise sogar diese Einstellung, weil sie der Idee von „Credentials“ widerspricht. Wenn Sie Cookies verwenden (und das tun wir, weil wir Session-basiert arbeiten), geben Sie die Origins explizit an.
Fehler Nr. 4: Vergessen, die benötigten Request-Header zu allowedHeaders hinzuzufügen.
Wenn Ihr Client Content-Type: application/json, X-CSRF-TOKEN oder einen anderen nicht „einfachen“ Header sendet, muss dieser erlaubt sein. Sonst fragt der Preflight „darf ich x-csrf-token senden?“ und der Server schweigt oder sagt „nein“, und der Browser macht nicht weiter. Das sieht oft so aus: „Mein PATCH funktioniert nicht“, obwohl PATCH einfach nicht stattgefunden hat.
Fehler Nr. 5: OPTIONS als „verdächtige Methode“ blockieren.
Intuitiv möchte man alles blockieren, was nicht GET/POST ist. Aber OPTIONS in der Browser-Welt ist keine „Geschäftsoperation“, sondern Teil des CORS-Mechanismus. Wenn Sie den Preflight nicht durchlassen, zerstören Sie Cross-Origin-Aufrufe selbst für öffentliche Ressourcen. OPTIONS global zu erlauben ist normalerweise sicherer, als später punktuell zu erraten, wo der Browser einen Preflight machen wird.
GO TO FULL VERSION