5. Getrennte Systeme
Getrennte Systeme
Abschnitt betitelt „Getrennte Systeme“In SEW liegen Oberfläche und API in derselben Next.js-Anwendung. Ein Aufruf auf /api/bookings geht an denselben Server, von dem auch die Seite kam, und funktioniert deshalb ohne Zutun. In der Praxis liegen die beiden Teile oft auf getrennten Adressen, in getrennten Repositories, betreut von getrennten Teams, und dann bricht auf einen Schlag eine ganze Reihe von Dingen, die vorher unsichtbar funktioniert haben.
Dieses Kapitel zeigt, was genau bricht und wie man es behebt. Der Schwerpunkt liegt auf dem zweiten Teil, weil die verbreitete Lösung darin besteht, CORS pauschal abzuschalten, und das ist keine Lösung, sondern eine Verlagerung des Problems auf die Benutzer.
Das Kapitel betrifft auch alle, die sich in Kapitel 1 für den Fullstack entschieden haben. Spätestens mit dem CMS in Kapitel 14 haben Sie ein zweites System unter einer zweiten Adresse, und sobald jemand eine Mobil-App oder ein Partnersystem anschließen will, sind Sie ohnehin hier.
Warum überhaupt trennen
Abschnitt betitelt „Warum überhaupt trennen“Für die Trennung sprechen: mehrere verschiedene Clients auf derselben Schnittstelle (Web, Mobil, Partnersystem), unabhängige Auslieferung der beiden Teile, unterschiedlicher Ressourcenbedarf, eine andere Technologie im Backend als im Frontend, und getrennte Verantwortung in größeren Teams.
Dagegen spricht die Rechnung, die man zahlt: zwei Deployments statt einem, ein zusätzlicher Netzwerkweg zwischen Frontend und Daten, ein Vertrag, der gepflegt werden muss, Typen, die an zwei Stellen stimmen müssen, und zwei Logs bei jeder Fehlersuche.
| Situation | Fullstack genügt | Trennung lohnt sich |
|---|---|---|
| Ein Client (Browser) | ja | |
| Mobil-App oder Partner geplant | ja | |
| Eine Person baut alles | ja | |
| Zwei bis drei Personen mit klarer Aufteilung | ja | |
| Backend soll in einer anderen Sprache entstehen | ja | |
| Betriebsaufwand soll klein bleiben | ja | |
| Frontend und Backend ändern sich in unterschiedlichem Takt | ja |
Für Ihr Projekt haben Sie diese Entscheidung in Kapitel 1 getroffen. Was jetzt kommt, ist die Technik dahinter.
Der Browser als Sicherheitsgrenze
Abschnitt betitelt „Der Browser als Sicherheitsgrenze“Eine Origin besteht aus drei Teilen: Schema, Host und Port. Unterscheidet sich einer davon, ist es eine andere Origin, und der Browser behandelt sie als fremd.
| Adresse | Gleiche Origin wie https://projekt.example.at? |
|---|---|
https://projekt.example.at/bookings | ja, der Pfad zählt nicht |
http://projekt.example.at | nein, anderes Schema |
https://api.projekt.example.at | nein, anderer Host |
https://projekt.example.at:8080 | nein, anderer Port |
Wie aus einem Projekt zwei Origins werden
Abschnitt betitelt „Wie aus einem Projekt zwei Origins werden“An dieser Stelle stolpern viele: Es ist doch ein Projekt, mit einem Frontend und einem Backend. Woher kommen plötzlich zwei Origins?
Die Antwort steckt in der Tabelle oben. Frontend und Backend sind zwei laufende Programme, und zwei Programme können nicht gleichzeitig denselben Port auf demselben Rechner belegen. Sobald Sie die beiden Teile trennen, braucht jeder Teil eine eigene Adresse - und weil der Pfad für die Origin nicht zählt, ist genau der eine Weg, den man nicht hat: Über verschiedene Pfade lassen sich zwei getrennte Programme nicht unterscheiden.
Im Fullstack-Fall stellt sich die Frage nicht. Dort liefert ein einziger Next.js-Prozess
sowohl die Seite als auch /api/bookings aus. Beides kommt von https://projekt.example.at,
und der Unterschied ist nur der Pfad, der für die Origin bedeutungslos ist. Eine Origin,
keine Regel verletzt, nichts zu konfigurieren.
Trennt man die beiden, ergeben sich die zwei Adressen von selbst:
| Umgebung | Frontend | Backend | Warum verschiedene Origin |
|---|---|---|---|
| Entwicklung am eigenen Rechner | http://localhost:3000 | http://localhost:8080 | anderer Port |
| Betrieb mit Subdomain | https://projekt.example.at | https://api.projekt.example.at | anderer Host |
| Backend bei einem Anbieter | https://projekt.example.at | https://projekt.onrender.com | anderer Host |
Der zweite Fall ist der, der am meisten überrascht: api.projekt.example.at klingt nach
„dieselbe Domain, nur ein Präfix davor”. Für den Browser ist es ein anderer Host und damit
eine fremde Origin - Subdomains erben keine Rechte. Dasselbe gilt in der Entwicklung schon
zwischen zwei Ports auf demselben localhost.
Daraus folgt der Bauplan für den Rest des Kapitels. Entweder Sie akzeptieren die zweite Origin und erlauben sie ausdrücklich - das ist CORS. Oder Sie sorgen dafür, dass der Browser gar nichts Fremdes zu sehen bekommt, indem ein Server dazwischen alles unter einer Adresse ausliefert - das sind BFF und Reverse Proxy weiter unten.
Die Same-Origin-Policy ist die älteste und wichtigste Schutzregel im Browser. Ohne sie könnte jede Seite, die Sie öffnen, im Hintergrund Ihr Webmail auslesen, solange Sie dort angemeldet sind.
Woher die Regel kommt und warum sie so aussieht, wie sie aussieht, erzählt das folgende Video entlang der Geschichte der ersten Browser:
Entscheidend ist, was die Regel genau verbietet, denn hier liegt der Ursprung der halben Sicherheitsprobleme dieses Jahres: Verboten ist das Lesen der Antwort. Das Absenden der Anfrage ist erlaubt. Eine fremde Seite darf ein Formular an Ihre Bank abschicken, und der Browser schickt sogar die Cookies mit. Sie darf nur nicht sehen, was zurückkommt.
Aus dieser Asymmetrie folgt zweierlei. Erstens ist CSRF überhaupt erst möglich, weil das Absenden gelingt, auch wenn niemand die Antwort liest (Kapitel 9). Zweitens, und das ist der am häufigsten missverstandene Punkt des ganzen Kapitels:
CORS im Detail
Abschnitt betitelt „CORS im Detail“Cross-Origin Resource Sharing ist der Mechanismus, mit dem ein Server dem Browser mitteilt, dass eine bestimmte fremde Origin die Antwort lesen darf. Die Erlaubnis kommt immer vom Server, nie vom Client.
Einfache Anfragen und Preflight
Abschnitt betitelt „Einfache Anfragen und Preflight“Manche Anfragen darf der Browser sofort abschicken und danach entscheiden, ob das Ergebnis gelesen werden darf. Das gilt für GET, HEAD und POST mit sehr einfachen Inhaltstypen (Formularkodierung, reiner Text) und ohne besondere Kopfzeilen.
Alles andere löst einen Preflight aus: Der Browser fragt vorher mit OPTIONS nach, ob die eigentliche Anfrage erlaubt wäre. Ausgelöst wird das unter anderem von PUT, PATCH, DELETE, von eigenen Kopfzeilen wie Authorization oder If-Match und, das ist der praktisch häufigste Grund, von Content-Type: application/json. Damit ist praktisch jeder Aufruf einer JSON-Schnittstelle preflight-pflichtig.
sequenceDiagram participant F as Frontend (projekt.example.at) participant A as API (api.projekt.example.at) F->>A: OPTIONS /buchungen<br/>Origin, Access-Control-Request-Method: POST A->>F: 204, Allow-Origin, Allow-Methods, Allow-Headers, Max-Age Note over F: Browser prüft die Antwort F->>A: POST /buchungen mit JSON A->>F: 201, Access-Control-Allow-Origin
Der Preflight kostet einen zusätzlichen Umlauf. Genau dafür gibt es Access-Control-Max-Age: Damit speichert der Browser die Erlaubnis für die angegebene Zeit und fragt nicht bei jeder Anfrage neu. Ohne diesen Header verdoppelt sich die Latenz jedes Schreibvorgangs.
Das Zusammenspiel von einfacher Anfrage, Preflight und den Antwortkopfzeilen fasst dieses Video noch einmal von Anfang an zusammen:
Die Kopfzeilen
Abschnitt betitelt „Die Kopfzeilen“| Kopfzeile | Bedeutung |
|---|---|
Access-Control-Allow-Origin | welche Origin die Antwort lesen darf, genau eine oder * |
Access-Control-Allow-Methods | welche Methoden erlaubt sind (nur in der Preflight-Antwort) |
Access-Control-Allow-Headers | welche Kopfzeilen der Client senden darf |
Access-Control-Allow-Credentials | ob Cookies und Anmeldedaten mitgeschickt werden dürfen |
Access-Control-Expose-Headers | welche Antwortkopfzeilen das Skript lesen darf, etwa ETag |
Access-Control-Max-Age | wie lange die Preflight-Antwort gilt |
Vary: Origin | Hinweis für Zwischenspeicher, dass die Antwort von der Origin abhängt |
Zwei dieser Zeilen führen regelmäßig zu Problemen.
Access-Control-Allow-Origin: * zusammen mit Allow-Credentials: true ist verboten, und der Browser lehnt die Kombination ab. Das ist beabsichtigt: Eine Schnittstelle, die Anmeldedaten akzeptiert und gleichzeitig jeder Herkunft antwortet, wäre für jede beliebige Website im Namen Ihrer angemeldeten Benutzer benutzbar. Die Lösung ist eine Positivliste erlaubter Origins, aus der die tatsächlich angefragte zurückgespiegelt wird.
Vary: Origin fehlt fast immer und rächt sich erst im Betrieb. Ohne diesen Header darf ein Zwischenspeicher die Antwort für eine Origin auch anderen ausliefern, und dann bekommt die Vorschauumgebung die Erlaubnis für die Produktion serviert oder umgekehrt.
const allowedOrigins = new Set([ "https://projekt.example.at", "https://test.projekt.example.at", "http://localhost:3000",]);
export function corsHeaders(origin: string | null) { if (!origin || !allowedOrigins.has(origin)) return {}; return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Methods": "GET,POST,PATCH,DELETE", "Access-Control-Allow-Headers": "Content-Type,Authorization,If-Match", "Access-Control-Expose-Headers": "ETag", "Access-Control-Max-Age": "600", "Vary": "Origin", };}Fehlerbilder lesen
Abschnitt betitelt „Fehlerbilder lesen“Die Browser-Konsole meldet einen CORS-Fehler, die Ursache liegt aber praktisch immer auf dem Server. Diese Zuordnung erspart viel Zeit:
| Meldung im Browser | Tatsächliche Ursache |
|---|---|
| „No ‘Access-Control-Allow-Origin’ header is present” | Server schickt den Header nicht, oft weil die Origin nicht in der Liste steht |
| „Response to preflight request doesn’t pass” | OPTIONS wird nicht beantwortet oder liefert einen Fehlercode |
| „Credentials flag is true, but Allow-Origin is ’*’” | die verbotene Kombination von oben |
| „Request header field X is not allowed” | fehlt in Access-Control-Allow-Headers |
| CORS-Fehler, obwohl die Konfiguration stimmt | die Antwort ist ein 500er, und Fehlerantworten tragen die CORS-Kopfzeilen oft nicht |
Der letzte Fall ist der lehrreichste. Ein abgestürzter Endpunkt sieht im Browser wie ein CORS-Problem aus, weil die Fehlerantwort ohne die Kopfzeilen zurückkommt. Wer dann an der CORS-Konfiguration schraubt, sucht am falschen Ort. Die Gegenprobe ist immer dieselbe:
curl -i -X OPTIONS https://api.projekt.example.at/bookings \ -H "Origin: https://projekt.example.at" \ -H "Access-Control-Request-Method: POST"Was curl zeigt, ist die Wahrheit über den Server. Was der Browser zeigt, ist seine Reaktion darauf.
Die Sitzung über Systemgrenzen
Abschnitt betitelt „Die Sitzung über Systemgrenzen“Sobald sich jemand anmeldet, muss die Anmeldung bei jeder weiteren Anfrage mitkommen. Über Origin-Grenzen hinweg gibt es dafür zwei Wege, und die Wahl hat Folgen bis in Kapitel 9 hinein.
Cookies
Abschnitt betitelt „Cookies“Ein Cookie schickt der Browser automatisch mit, wenn die Anfrage zur Domain des Cookies passt. Vier Attribute sind Pflichtstoff:
HttpOnlyverbietet den Zugriff aus JavaScript. Damit kann eine XSS-Lücke das Sitzungscookie nicht auslesen.Secureerlaubt die Übertragung nur über HTTPS.SameSitebestimmt, ob das Cookie bei Anfragen von fremden Seiten mitgeschickt wird.Strictschickt es nie,Laxnur bei normaler Navigation,Noneimmer.Domainentscheidet über die Reichweite. Ein Cookie fürprojekt.example.atgilt auch fürapi.projekt.example.at, wenn es entsprechend gesetzt ist.
Der Fallstrick heißt SameSite=None: Ohne gleichzeitiges Secure wird das Cookie von modernen Browsern verworfen, und zwar wortlos. Man sieht dann eine Anmeldung, die scheinbar funktioniert und deren Sitzung nie ankommt.
Wenn Frontend und API Unternamen derselben Domain sind, ist die Cookie-Variante die angenehmere: SameSite=Lax reicht, die Cookies gelten für beide, und nichts liegt in JavaScript herum.
Bearer-Tokens
Abschnitt betitelt „Bearer-Tokens“Die Alternative ist ein Token, das der Client bei jeder Anfrage im Authorization-Header mitschickt. Wo dieses Token liegt, ist die eigentliche Frage.
Im localStorage ist es bequem: Es überlebt einen Neuladen der Seite und ist von überall lesbar. Von überall lesbar heißt allerdings auch: Jede XSS-Lücke, egal in welchem Winkel der Anwendung, gibt die komplette Sitzung heraus, und der Angreifer kann sie mitnehmen.
In einer JavaScript-Variable im Speicher ist es sicherer, weil kein dauerhafter Ort existiert, aber bei jedem Neuladen weg. Man braucht dann einen Weg, es wiederzubeschaffen, üblicherweise ein Refresh-Token in einem HttpOnly-Cookie, womit man wieder bei Cookies ist.
| Cookie-Sitzung | Bearer-Token | |
|---|---|---|
| Übertragung | automatisch durch den Browser | manuell im Authorization-Header |
| Schutz vor XSS-Diebstahl | gut mit HttpOnly | schwach im localStorage |
| CSRF-Gefahr | vorhanden, SameSite und Token nötig | gering, weil nichts automatisch mitgeht |
| Fremde Clients (Mobil, Partner) | umständlich | natürlich |
| Aufwand im Frontend | gering | Speicherung, Erneuerung, Abmeldung selbst bauen |
Es gibt hier keine allgemein richtige Antwort. Für ein Projekt, dessen Frontend und API unter derselben Hauptdomain liegen, ist die Cookie-Variante meist die vernünftigere. Sobald eine Mobil-App oder ein fremder Client dazukommt, führt an Tokens wenig vorbei. Was Sie in jedem Fall vermeiden sollten, ist die Kombination aus Token im localStorage und einer Anwendung, die fremde Inhalte darstellt.
Das BFF-Muster
Abschnitt betitelt „Das BFF-Muster“Es gibt einen dritten Weg, der beide Probleme umgeht: Der Browser spricht ausschließlich mit seiner eigenen Origin, und der Server dahinter holt die Daten. Dieses Muster heißt Backend for Frontend.
flowchart LR B[Browser] -->|eigene Origin, Cookie| F[Frontend-Server] F -->|Server zu Server, Token| A[API] A --> D[(Datenbank)]
Der Gewinn ist beträchtlich. CORS entfällt vollständig, weil keine fremde Origin im Spiel ist. Das Zugriffstoken für die API bleibt auf dem Server und kommt nie in den Browser, womit die ganze localStorage-Diskussion entfällt. Und der Frontend-Server kann mehrere API-Aufrufe zu einer Antwort bündeln, was auf einer Mobilverbindung deutlich spürbar ist.
In Next.js gibt es zwei Bauformen. Ein Route Handler unter /api/... nimmt die Anfrage des Browsers entgegen, hängt das Token an und leitet an die eigentliche API weiter. Und Server Components holen ihre Daten ohnehin serverseitig: Dort läuft fetch nicht im Browser, sondern auf dem Server, und die Same-Origin-Policy spielt keine Rolle, weil kein Browser beteiligt ist. Das ist der Grund, warum in einer Next.js-Anwendung mit Server Components ein Teil der CORS-Fragen gar nicht erst auftaucht.
Die Kosten stehen auf der anderen Seite: ein zusätzlicher Netzwerkweg pro Anfrage, ein weiterer Ort für Fehler, und der Frontend-Server wird zu einem Bauteil, das ausfallen kann.
Den Vertrag typsicher halten
Abschnitt betitelt „Den Vertrag typsicher halten“Zwei getrennte Anwendungen haben zwei Vorstellungen davon, wie eine Buchung aussieht. Wenn beide von Hand gepflegt werden, laufen sie auseinander, und zwar beim ersten umbenannten Feld. Der Fehler zeigt sich dann zur Laufzeit als undefined, nicht beim Übersetzen.
Die Gegenmaßnahme ist, die Typen aus der OpenAPI-Beschreibung von Kapitel 4 zu erzeugen:
npx openapi-typescript docs/openapi.yaml -o src/lib/api/schema.d.tsDer Aufruf gehört in ein npm-Skript und in die CI-Pipeline. Ändert sich der Vertrag, ändern sich die Typen, und der Übersetzungsvorgang im Frontend schlägt an genau den Stellen fehl, die angepasst werden müssen. Das ist der Unterschied zwischen einem Fehler am Dienstagnachmittag im Editor und einem Fehler am Freitag im Betrieb.
Daneben gehört der Zugriff auf die API an eine einzige Stelle im Frontend: ein Modul, das die Basis-URL kennt, das Token oder die Cookie-Einstellung setzt, Fehlerantworten einheitlich in Ausnahmen übersetzt und Wiederholungen behandelt. Verteilt man das über die Komponenten, steht die Basis-URL an vierzehn Stellen, und die fünfzehnte wird beim Umzug vergessen.
Was passiert, wenn sich der Vertrag ändert, ist eine eigene Frage: Additive Änderungen sind harmlos, entfernte oder umbenannte Felder brechen Aufrufer. Der übliche Weg führt über eine Übergangszeit, in der das alte Feld noch mitgeliefert und als überholt gekennzeichnet wird. Die Prüfung, ob Implementierung und Beschreibung noch zusammenpassen, kommt in Kapitel 7 als Vertragstest.
Betrieb getrennter Systeme
Abschnitt betitelt „Betrieb getrennter Systeme“Zwei Anwendungen heißen in Dokploy zwei Projekte, zwei Container, zwei Unternamen und zwei Deployments. Dazu kommt die Frage der Reihenfolge, die man einmal durchdacht haben sollte.
Bei einer additiven Änderung (neues Feld, neuer Endpunkt) wird zuerst die API ausgeliefert und danach das Frontend. Das alte Frontend läuft mit der neuen API weiter, weil es das neue Feld schlicht ignoriert.
Bei einer entfernenden Änderung ist es umgekehrt: erst das Frontend, das das Feld nicht mehr braucht, dann, nach einer Wartezeit, die API. Wer es andersherum macht, hat für die Dauer des zweiten Deployments ein Frontend, das gegen eine API läuft, die es nicht mehr bedient.
Jede Umgebung braucht ihre eigene Origin-Liste, und diese Tabelle gehört nach docs/architektur.md:
| Umgebung | Frontend | API | Erlaubte Origins in der API |
|---|---|---|---|
| Lokal | http://localhost:3000 | http://localhost:4000 | http://localhost:3000 |
| Vorschau | test.projekt.example.at | test-api.projekt.example.at | die Vorschau-Frontend-Adresse |
| Produktion | projekt.example.at | api.projekt.example.at | die Produktions-Frontend-Adresse |
Die häufigste Betriebspanne in diesem Aufbau ist eine Vorschauumgebung, deren Adresse in der Produktions-API freigeschaltet wurde, weil es beim Ausprobieren schneller ging. Damit darf die Testoberfläche auf echte Daten zugreifen, und niemand nimmt es zurück.
Häufige Stolperfallen
Abschnitt betitelt „Häufige Stolperfallen“- CORS „gelöst”, indem alles erlaubt wird. Meist zusammen mit Credentials, was der Browser ohnehin ablehnt, worauf noch mehr abgeschaltet wird.
- CORS für Zugriffsschutz halten.
curlinteressiert sich nicht dafür. Die Berechtigung prüft der Server, bei jeder Anfrage. SameSite=NoneohneSecure. Das Cookie wird verworfen, ohne dass eine Fehlermeldung erscheint.- Token im
localStorageplus eine XSS-Lücke. Die Sitzung ist dann kopierbar, und ein Abmelden hilft dem Angreifer nicht mehr weg. - Am CORS schrauben, obwohl der Endpunkt abstürzt. Immer mit
curlgegenprüfen, bevor Sie die Konfiguration ändern. Vary: Originvergessen. Ein Zwischenspeicher liefert dann die Erlaubnis der einen Umgebung an eine andere aus.- Preflight ohne
Max-Age. Jeder Schreibvorgang kostet einen zusätzlichen Umlauf, spürbar auf Mobilverbindungen. - Typen doppelt pflegen. Beim ersten umbenannten Feld läuft es auseinander, und der Fehler erscheint erst zur Laufzeit.
- Vorschau-Origin in der Produktions-API freischalten, weil es beim Testen praktisch war.
Lernergebnisse
Abschnitt betitelt „Lernergebnisse“- Erklären: den Begriff Origin definieren und für gegebene Adressen entscheiden, ob sie gleich sind.
- Erklären: die Same-Origin-Policy erklären, insbesondere den Unterschied zwischen verbotenem Lesen und erlaubtem Senden, und daraus die Möglichkeit von CSRF ableiten.
- Erklären: den CORS-Ablauf mit und ohne Preflight beschreiben und benennen, was einen Preflight auslöst.
- Anwenden: eine API so konfigurieren, dass ein Frontend auf einer anderen Origin sie mit Anmeldung nutzen kann, mit Positivliste statt Wildcard.
- Analysieren: eine CORS-Fehlermeldung im Browser der tatsächlichen Ursache auf dem Server zuordnen und mit
curlgegenprüfen. - Beurteilen: begründen, warum CORS keinen Zugriffsschutz darstellt.
- Analysieren: Cookie- und Token-Ansatz hinsichtlich XSS, CSRF, fremder Clients und Aufwand gegenüberstellen und eine begründete Wahl treffen.
- Beurteilen: entscheiden, wann ein BFF oder ein Reverse-Proxy-Pfad sinnvoll ist und wann direkte Aufrufe genügen.
- Anwenden: einen typisierten Client aus dem Schnittstellenvertrag erzeugen und den Zugriff an einer Stelle bündeln.
- Anwenden: zwei Anwendungen getrennt ausliefern und die Reihenfolge bei additiven und entfernenden Änderungen begründen.
Arbeit am Jahresprojekt
Abschnitt betitelt „Arbeit am Jahresprojekt“- Das Frontend spricht das Backend über eine festgelegte Origin an, entweder getrennt mit Positivliste in der CORS-Konfiguration oder über BFF beziehungsweise Proxy-Pfad.
- Die Sitzung funktioniert über die Systemgrenze hinweg, mit dokumentierter Entscheidung für Cookie oder Token.
- Typen und Client werden aus der OpenAPI-Beschreibung erzeugt, der Erzeugungsschritt ist im Repository hinterlegt.
- Die Origin-Tabelle für lokale, Vorschau- und Produktionsumgebung steht in
docs/architektur.md. - Eintrag im Entscheidungsprotokoll: Trennung oder Zusammenlegung, Cookie oder Token, jeweils mit Begründung.
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe 17 - CORS-Fehler nachstellen und sauber beheben
- Aufgabe 18 - Cookie gegen Token: zwei Varianten derselben Anmeldung
- Aufgabe 19 - BFF-Proxy in Next.js bauen
- Aufgabe 20 - Typisierten Client generieren und Vertragsbruch provozieren