Zum Inhalt springen

5. Getrennte Systeme

Zu Zen-Modus wechseln

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.

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.

SituationFullstack genügtTrennung lohnt sich
Ein Client (Browser)ja
Mobil-App oder Partner geplantja
Eine Person baut allesja
Zwei bis drei Personen mit klarer Aufteilungja
Backend soll in einer anderen Sprache entstehenja
Betriebsaufwand soll klein bleibenja
Frontend und Backend ändern sich in unterschiedlichem Taktja

Für Ihr Projekt haben Sie diese Entscheidung in Kapitel 1 getroffen. Was jetzt kommt, ist die Technik dahinter.

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.

AdresseGleiche Origin wie https://projekt.example.at?
https://projekt.example.at/bookingsja, der Pfad zählt nicht
http://projekt.example.atnein, anderes Schema
https://api.projekt.example.atnein, anderer Host
https://projekt.example.at:8080nein, anderer Port

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:

UmgebungFrontendBackendWarum verschiedene Origin
Entwicklung am eigenen Rechnerhttp://localhost:3000http://localhost:8080anderer Port
Betrieb mit Subdomainhttps://projekt.example.athttps://api.projekt.example.atanderer Host
Backend bei einem Anbieterhttps://projekt.example.athttps://projekt.onrender.comanderer 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:

Abspielen
The Same Origin Policy - Hacker History
LiveOverflow

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:

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.

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:

Abspielen
Was ist CORS? // deutsch
the native web GmbH
KopfzeileBedeutung
Access-Control-Allow-Originwelche Origin die Antwort lesen darf, genau eine oder *
Access-Control-Allow-Methodswelche Methoden erlaubt sind (nur in der Preflight-Antwort)
Access-Control-Allow-Headerswelche Kopfzeilen der Client senden darf
Access-Control-Allow-Credentialsob Cookies und Anmeldedaten mitgeschickt werden dürfen
Access-Control-Expose-Headerswelche Antwortkopfzeilen das Skript lesen darf, etwa ETag
Access-Control-Max-Agewie lange die Preflight-Antwort gilt
Vary: OriginHinweis 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",
};
}

Die Browser-Konsole meldet einen CORS-Fehler, die Ursache liegt aber praktisch immer auf dem Server. Diese Zuordnung erspart viel Zeit:

Meldung im BrowserTatsä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 stimmtdie 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:

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

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.

Ein Cookie schickt der Browser automatisch mit, wenn die Anfrage zur Domain des Cookies passt. Vier Attribute sind Pflichtstoff:

  • HttpOnly verbietet den Zugriff aus JavaScript. Damit kann eine XSS-Lücke das Sitzungscookie nicht auslesen.
  • Secure erlaubt die Übertragung nur über HTTPS.
  • SameSite bestimmt, ob das Cookie bei Anfragen von fremden Seiten mitgeschickt wird. Strict schickt es nie, Lax nur bei normaler Navigation, None immer.
  • Domain entscheidet über die Reichweite. Ein Cookie für projekt.example.at gilt auch für api.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.

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-SitzungBearer-Token
Übertragungautomatisch durch den Browsermanuell im Authorization-Header
Schutz vor XSS-Diebstahlgut mit HttpOnlyschwach im localStorage
CSRF-Gefahrvorhanden, SameSite und Token nötiggering, weil nichts automatisch mitgeht
Fremde Clients (Mobil, Partner)umständlichnatürlich
Aufwand im FrontendgeringSpeicherung, 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.

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.

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:

Terminal-Fenster
npx openapi-typescript docs/openapi.yaml -o src/lib/api/schema.d.ts

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

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:

UmgebungFrontendAPIErlaubte Origins in der API
Lokalhttp://localhost:3000http://localhost:4000http://localhost:3000
Vorschautest.projekt.example.attest-api.projekt.example.atdie Vorschau-Frontend-Adresse
Produktionprojekt.example.atapi.projekt.example.atdie 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.

  1. CORS „gelöst”, indem alles erlaubt wird. Meist zusammen mit Credentials, was der Browser ohnehin ablehnt, worauf noch mehr abgeschaltet wird.
  2. CORS für Zugriffsschutz halten. curl interessiert sich nicht dafür. Die Berechtigung prüft der Server, bei jeder Anfrage.
  3. SameSite=None ohne Secure. Das Cookie wird verworfen, ohne dass eine Fehlermeldung erscheint.
  4. Token im localStorage plus eine XSS-Lücke. Die Sitzung ist dann kopierbar, und ein Abmelden hilft dem Angreifer nicht mehr weg.
  5. Am CORS schrauben, obwohl der Endpunkt abstürzt. Immer mit curl gegenprüfen, bevor Sie die Konfiguration ändern.
  6. Vary: Origin vergessen. Ein Zwischenspeicher liefert dann die Erlaubnis der einen Umgebung an eine andere aus.
  7. Preflight ohne Max-Age. Jeder Schreibvorgang kostet einen zusätzlichen Umlauf, spürbar auf Mobilverbindungen.
  8. Typen doppelt pflegen. Beim ersten umbenannten Feld läuft es auseinander, und der Fehler erscheint erst zur Laufzeit.
  9. Vorschau-Origin in der Produktions-API freischalten, weil es beim Testen praktisch war.
  • 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 curl gegenprü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.
  • 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.
  • 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