Zum Inhalt springen

CORS im Detail: Cross-Origin Resource Sharing konfigurieren

Verstehe, wie CORS auf Protokollebene arbeitet, und implementiere sichere, korrekte Konfigurationen ohne Wildcard-Allow-All-Muster.

5 Min. Lesezeit
Sequenzdiagramm, das die CORS-Preflight-Anfrage und die Antwort-Header zwischen Browser und Server zeigt

CORS-Fehler sind der Fluch der Frontend-Entwicklung. Der Instinkt, wenn man "Access-Control-Allow-Origin" in Rot sieht, ist, Access-Control-Allow-Origin: * auf den Server zu werfen und weiterzumachen. Das funktioniert in der Entwicklung und reißt Sicherheitslücken in der Produktion auf.

Wer CORS auf Protokollebene versteht—was der Browser sendet, was der Server antworten sollte und warum es den Preflight gibt—verwandelt eine frustrierende Debugging-Session in eine geradlinige Konfiguration.

Was passiert, bevor dein Code läuft

CORS wird vom Browser durchgesetzt, nicht vom Server. Der Server deklariert lediglich seine Richtlinie über Antwort-Header. Der Browser entscheidet anhand dieser Header, ob die Antwort blockiert wird.

tstypescript
// ❌ Common misconception: CORS blocks the request
// Reality: The request IS sent. The browser blocks the RESPONSE.
// Your server processes the request either way!
 
// This means CORS alone doesn't prevent server-side effects.
// A POST that creates a database record still executes.
// The browser just hides the response from JavaScript.
tstypescript
// The browser's CORS decision flow:
interface CORSCheck {
  step1: "Is request origin different from resource origin?";
  step2: "If same-origin → allow, no CORS headers needed";
  step3: "If cross-origin → check Access-Control-Allow-Origin header";
  step4: "If header matches origin → allow JavaScript to read response";
  step5: "If header missing or mismatched → block response from JS";
}
 
// For non-simple requests, add a preflight step BEFORE step 1:
interface PreflightCheck {
  trigger: "Custom headers, non-GET/POST methods, or non-simple content types";
  action: "Send OPTIONS request with Access-Control-Request-* headers";
  serverResponds: "With Access-Control-Allow-* headers declaring policy";
  browserDecides: "Whether to send the actual request based on the policy";
}

Die entscheidende Erkenntnis ist, dass CORS ein Verhandlungsprotokoll zwischen Browser und Server ist. Server-zu-Server-Anfragen, curl und Postman haben mit CORS überhaupt nichts zu tun, weil kein Browser die Richtlinie durchsetzt.

Preflight-Anfragen entmystifiziert

"Einfache" Anfragen (GET, POST mit Formular-Content-Types, begrenzte Header) überspringen den Preflight. Alles andere löst eine OPTIONS-Anfrage aus, die erfolgreich sein muss, bevor die eigentliche Anfrage gesendet wird.

tstypescript
// ❌ Not understanding why a preflight is triggered
// "My GET request shouldn't need a preflight!"
// It does if you added custom headers:
fetch("https://api.example.com/data", {
  headers: {
    Authorization: "Bearer token123",  // Custom header → preflight
    "X-Request-Id": "abc",            // Custom header → preflight
  },
});
tstypescript
// Express middleware: properly handling preflight
import express, { Request, Response, NextFunction } from "express";
 
const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "https://staging.example.com",
]);
 
function corsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const origin = req.headers.origin;
 
  // Only set CORS headers for allowed origins
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin"); // Critical for caching
 
    // Handle preflight
    if (req.method === "OPTIONS") {
      res.setHeader(
        "Access-Control-Allow-Methods",
        "GET, POST, PUT, DELETE, PATCH"
      );
      res.setHeader(
        "Access-Control-Allow-Headers",
        "Content-Type, Authorization, X-Request-Id"
      );
      res.setHeader(
        "Access-Control-Max-Age",
        "86400" // Cache preflight for 24 hours
      );
      res.status(204).end();
      return;
    }
  }
 
  next();
}
 
const app = express();
app.use(corsMiddleware);

Der Header Vary: Origin ist entscheidend und wird häufig vergessen. Ohne ihn könnte ein CDN eine Antwort mit den CORS-Headern eines Origins cachen und sie an einen anderen Origin ausliefern—was zu CORS-Fehlern führt.

Dynamische Origin-Validierung

Produktions-APIs müssen oft mehrere Origins zulassen, einschließlich Subdomains und Preview-Deployment-URLs. Regex-basierte Validierung löst das ohne Wildcard.

tstypescript
// ❌ Reflecting any origin back (equivalent to wildcard)
function unsafeCors(req: Request, res: Response, next: NextFunction) {
  res.setHeader(
    "Access-Control-Allow-Origin",
    req.headers.origin ?? "*" // Reflects attacker's origin!
  );
  next();
}
tstypescript
// ✅ Validating origins against a pattern
function isAllowedOrigin(origin: string): boolean {
  const allowedPatterns = [
    /^https:\/\/app\.example\.com$/,
    /^https:\/\/[a-z0-9-]+\.preview\.example\.com$/,
    /^https:\/\/staging\.example\.com$/,
  ];
 
  // In development, also allow localhost
  if (process.env.NODE_ENV === "development") {
    allowedPatterns.push(/^http:\/\/localhost:\d+$/);
  }
 
  return allowedPatterns.some(pattern => pattern.test(origin));
}
 
function secureCorsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const origin = req.headers.origin;
 
  if (origin && isAllowedOrigin(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
 
    if (req.method === "OPTIONS") {
      res.setHeader(
        "Access-Control-Allow-Methods",
        "GET, POST, PUT, DELETE"
      );
      res.setHeader(
        "Access-Control-Allow-Headers",
        "Content-Type, Authorization"
      );
      res.setHeader("Access-Control-Max-Age", "86400");
      res.status(204).end();
      return;
    }
  }
 
  next();
}

Die Regex-Muster sind strikt: Sie matchen exakte Domänenstrukturen, keine Teilstrings. Ein lockeres Muster wie /example\.com/ würde auch evil-example.com matchen—verankere deine Muster immer mit ^ und $.

Credentials und Cookies über Origins hinweg

Wenn deine Cross-Origin-Anfrage Cookies senden oder HTTP-Authentifizierung verwenden muss, wird CORS restriktiver. Der Wildcard * ist mit Credentials ausdrücklich verboten.

tstypescript
// ❌ This doesn't work: wildcard + credentials
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
// Browser error: Cannot use wildcard with credentials
 
// ❌ Reflecting origin without validation + credentials
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
// Security hole: any site can make authenticated requests
tstypescript
// ✅ Explicit origin with credentials
function credentialedCorsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const origin = req.headers.origin;
 
  if (origin && isAllowedOrigin(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
 
    // Expose specific response headers to JavaScript
    res.setHeader(
      "Access-Control-Expose-Headers",
      "X-Total-Count, X-Request-Id"
    );
 
    if (req.method === "OPTIONS") {
      res.setHeader(
        "Access-Control-Allow-Methods",
        "GET, POST, PUT, DELETE"
      );
      res.setHeader(
        "Access-Control-Allow-Headers",
        "Content-Type, Authorization"
      );
      res.setHeader("Access-Control-Max-Age", "3600");
      res.status(204).end();
      return;
    }
  }
 
  next();
}
tstypescript
// Client-side: must explicitly opt into credentials
const response = await fetch("https://api.example.com/user", {
  credentials: "include", // Send cookies cross-origin
  headers: {
    "Content-Type": "application/json",
  },
});

Der Header Access-Control-Expose-Headers wird oft übersehen. Standardmäßig kann JavaScript nur sechs "CORS-safelisted" Antwort-Header lesen. Eigene Header wie X-Total-Count für Paginierung sind unsichtbar, wenn sie nicht explizit freigegeben werden.

CORS-Konfiguration für gängige Frameworks

Jedes Framework behandelt CORS anders. Hier sind sichere Konfigurationen für die populärsten.

tstypescript
// Next.js API route
import type { NextApiRequest, NextApiResponse } from "next";
 
const allowedOrigins = ["https://app.example.com"];
 
export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const origin = req.headers.origin;
 
  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }
 
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type");
    res.setHeader("Access-Control-Max-Age", "86400");
    return res.status(204).end();
  }
 
  res.json({ data: "response" });
}
pypython
# FastAPI with strict CORS
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
 
app = FastAPI()
 
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://app.example.com",
        "https://staging.example.com",
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    expose_headers=["X-Total-Count"],
    max_age=86400,
)
gogo
// Go with chi router
package main
 
import (
    "net/http"
    "github.com/go-chi/cors"
)
 
func main() {
    r := chi.NewRouter()
 
    r.Use(cors.Handler(cors.Options{
        AllowedOrigins:   []string{"https://app.example.com"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE"},
        AllowedHeaders:   []string{"Content-Type", "Authorization"},
        ExposedHeaders:   []string{"X-Total-Count"},
        AllowCredentials: true,
        MaxAge:           86400,
    }))
}

CORS-Probleme systematisch debuggen

Wenn ein CORS-Fehler auftaucht, gehe systematisch vor, statt wahllos Header zu ändern.

tstypescript
interface CORSDebugChecklist {
  step: string;
  check: string;
  fix: string;
}
 
const debugChecklist: CORSDebugChecklist[] = [
  {
    step: "1. Check the actual error message",
    check: "Browser console shows which header is missing or wrong",
    fix: "Add the specific header mentioned in the error",
  },
  {
    step: "2. Inspect the preflight",
    check: "Network tab → filter by OPTIONS → check response headers",
    fix: "Ensure OPTIONS returns 204 with correct CORS headers",
  },
  {
    step: "3. Verify Vary header",
    check: "Response includes 'Vary: Origin'",
    fix: "Add Vary header to prevent CDN caching issues",
  },
  {
    step: "4. Check credentials mode",
    check: "If using credentials, origin cannot be wildcard",
    fix: "Set explicit origin and credentials: true",
  },
  {
    step: "5. Check exposed headers",
    check: "Custom response headers readable in JS?",
    fix: "Add Access-Control-Expose-Headers for custom headers",
  },
  {
    step: "6. Check Max-Age",
    check: "Browser might cache a failed preflight",
    fix: "Clear browser cache or use incognito to test",
  },
];

Die wichtigsten Erkenntnisse

CORS ist ein Sicherheitsmechanismus, kein Hindernis, das man umgehen muss. Der Wildcard * ist nur für wirklich öffentliche APIs angemessen, die statische Daten ohne Authentifizierung ausliefern. Alles andere verdient eine explizite Origin-Validierung, korrekte Preflight-Behandlung und den Header Vary: Origin, um Caching-Katastrophen zu verhindern.

Die häufigsten CORS-Bugs haben drei Ursachen: den Origin ohne Validierung zu spiegeln (macht Credentials wertlos), Vary: Origin zu vergessen (verursacht CDN-bedingte, sporadische Fehler) und den OPTIONS-Preflight nicht zu behandeln (liefert 404 oder 405 statt 204). Behebe diese drei Muster, und du eliminierst 90 % der CORS-Debugging-Sessions.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX