Während SameSite-Cookies eine Browser-seitige Schutz-Schicht sind, ist das CSRF-Token-Pattern die klassische Anwendungs-seitige Verteidigung — bewährt seit den frühen 2000ern, eingebaut in fast alle modernen Web-Frameworks. Wer eine traditionelle Form-basierte App baut, hat damit eine zweite, unabhängige Schutz-Schicht. Dieser Artikel zeigt die drei verbreiteten Patterns, ihre Trade-offs und die typischen Implementierungs-Fehler.
Das Grundprinzip
Ein CSRF-Token ist ein unvorhersehbarer Wert, den der Server erzeugt und der bei sensiblen Anfragen mitgeschickt werden muss. Der Server prüft, dass das Token zur aktuellen Session gehört und nicht abgelaufen ist.
Warum das CSRF verhindert: Eine Angreifer-Seite kann zwar Cookies cross-site mitsenden (oder konnte es vor SameSite), aber sie kennt das Token nicht — die Same-Origin-Policy verhindert, dass JavaScript auf evil.example die Page von bank.example liest und das Token extrahiert.
Klassisches Pattern (Synchronizer-Token):
<!-- Server-rendered Form mit Token -->
<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="r4nd0m-512-bit-value">
<input name="to" value="...">
<input name="amount" value="...">
<button>Senden</button>
</form>Server-Logik:
@app.post('/transfer')
def transfer():
# Token aus Form-Body
submitted = request.form.get('csrf_token')
# Token aus Server-State (Session)
expected = session.get('csrf_token')
if not submitted or submitted != expected:
abort(403, 'CSRF-Token ungültig')
# ... eigentliche LogikDer/die Angreifer:in auf evil.example kann die Form per JavaScript abschicken — aber das Token-Feld bleibt leer. Server lehnt ab.
Das Synchronizer-Token-Pattern
Das klassische und sicherste Pattern. Drei Anforderungen:
1. Token wird vom Server erzeugt — kryptografisch zufällig, mindestens 128 Bit Entropie (besser 256).
2. Token wird im Server-State gespeichert — typischerweise in der Session. Server merkt sich pro User-Session ein Token.
3. Token wird bei jeder schreibenden Anfrage geprüft — Mismatch oder Fehlen → Anfrage abgelehnt.
Token-Erzeugung in Python:
import secrets
def generate_csrf_token():
return secrets.token_urlsafe(32) # 32 Bytes = 256 Bit
@app.before_request
def ensure_csrf_token():
if 'csrf_token' not in session:
session['csrf_token'] = generate_csrf_token()Pro Form ausgeben:
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
<!-- ... -->
</form>Token-Rotation:
Optional rotiert man das Token nach jeder erfolgreichen Form-Submission oder bei Login. Das verhindert Token-Reuse-Angriffe und reduziert die Lebensdauer eines geleakten Tokens.
Vorteile des Synchronizer-Pattern:
- Sehr stark — Token muss exakt mit Server-State übereinstimmen.
- Resistent gegen Cookie-Manipulation — Token ist nicht im Cookie, sondern im Body.
- Standard in den meisten Frameworks — Django, Rails, Spring, Laravel.
Nachteile:
- Stateful — Server muss pro Session ein Token speichern. Schwer in stateless-Architekturen (z. B. Multi-Region-Backend ohne sticky Sessions).
- Bei vielen parallel offenen Tabs kann Token-Mismatch auftreten — wenn das Token rotiert wird, alte Tab-Token werden ungültig.
Das Double-Submit-Cookie-Pattern
Eine stateless-Variante: Das Token wird sowohl in einem Cookie als auch im Request-Body mitgeschickt. Server prüft, dass beide übereinstimmen.
Pattern:
// 1. Server schickt Cookie beim ersten Page-Load
Set-Cookie: csrf_token=r4nd0m-value; SameSite=Lax; Secure
// 2. Client-JS liest das Cookie und setzt es in Form/Header
const token = document.cookie.match(/csrf_token=([^;]+)/)?.[1];
fetch('/api/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: '...', amount: 100 }),
});
// 3. Server prüft, dass Cookie-Wert und Header-Wert übereinstimmenServer-Check (Express-Pseudo):
app.post('/api/transfer', (req, res) => {
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).send('CSRF rejection');
}
// ... eigentliche Logik
});Warum das CSRF verhindert:
- Eine Angreifer-Seite kann das Cookie nicht lesen (Same-Origin-Policy).
- Sie kann das Cookie zwar bei einer Cross-Site-Anfrage mitsenden lassen (wenn SameSite=None), aber sie kann nicht das Header-Feld setzen mit dem passenden Wert.
- Token-Cookie und Header-Token sind nur dann gleich, wenn die Anfrage von eigenem JavaScript erzeugt wurde — und das läuft auf der eigenen Origin.
Vorteile:
- Stateless — keine Server-Session-Speicherung nötig.
- Skaliert horizontal — beliebig viele Backend-Instanzen, keine Sticky-Sessions.
- Funktioniert mit JSON-APIs.
Nachteile und Stolperfallen:
- Cookie muss vom Server gesetzt werden — und der Browser sendet es zurück. Das ist nicht trivial in komplexen Setups.
- Schwächer als Synchronizer-Pattern, wenn der Angreifer das Token-Cookie setzen kann (z. B. via Cookie-Tossing-Angriffe von Sub-Domains, XSS).
HttpOnlyist hier nicht möglich — JS muss das Cookie lesen, sonst kann es nicht im Header gesendet werden.
Signiertes Double-Submit als Verbesserung:
// Token = HMAC(server_secret, session_id + random_value)
// Cookie enthält das Token mit der Signatur
// Server prüft Signatur + Übereinstimmung
function generateToken(sessionId, secret) {
const random = crypto.randomBytes(16).toString('hex');
const hmac = crypto.createHmac('sha256', secret)
.update(`${sessionId}.${random}`)
.digest('hex');
return `${random}.${hmac}`;
}Damit kann der Server prüfen, dass das Token kryptografisch zu seiner Session gehört — auch wenn ein Angreifer das Cookie setzen könnte, kann er keine gültige Signatur erzeugen.
Encrypted Token Pattern
Eine Variante des Double-Submit-Patterns, die das Token verschlüsselt im Cookie speichert. Server kann das Token entschlüsseln und mit Session-Daten vergleichen.
from cryptography.fernet import Fernet
SECRET_KEY = Fernet(b'...') # Server-Geheimnis
def generate_encrypted_token(session_id):
plain = f"{session_id}|{int(time.time())}"
return SECRET_KEY.encrypt(plain.encode()).decode()
def verify_encrypted_token(token, session_id):
try:
plain = SECRET_KEY.decrypt(token.encode()).decode()
stored_session, timestamp = plain.split('|')
if stored_session != session_id:
return False
if int(time.time()) - int(timestamp) > 3600:
return False # Token zu alt
return True
except Exception:
return FalseVorteile gegenüber unsigniertem Double-Submit:
- Session-Binding kryptografisch garantiert.
- Token-Verfallsdatum integriert.
- Keine Server-Session-Tabelle für CSRF-Tokens nötig.
Dieses Pattern ist die stateless-Alternative, die heute in vielen modernen Frameworks (z. B. Rails 5+) verbreitet ist.
Framework-Defaults
Die meisten modernen Web-Frameworks haben CSRF-Schutz eingebaut:
| Framework | Default-Pattern | Wie aktiviert |
|---|---|---|
| Django | Synchronizer-Token mit Session-State | Middleware aktiv per Default; {% csrf_token %} in Template |
| Rails | Synchronizer-Token | Active per Default; <%= csrf_meta_tags %> und automatische Form-Helpers |
| Spring Security | Synchronizer-Token + Double-Submit-Option | CsrfFilter aktiv per Default seit Spring Security 4.0 |
| Laravel | Synchronizer-Token | @csrf in Blade-Templates; Middleware aktiv |
| ASP.NET Core | Signed Double-Submit (AntiForgery) | Eingebaut; [ValidateAntiForgeryToken]-Attribut |
| Express.js | Keine Default — Middleware nötig | csurf-Paket (deprecated; Empfehlung: csrf-csrf-Paket) |
| Next.js / Remix | Keine Default — eigene Implementation | Manuell mit csrf-csrf oder ähnlich |
| Hapi.js | crumb-Plugin | Manuell aktivieren |
| Flask | Keine Default — Extension nötig | Flask-WTF mit CSRFProtect |
| FastAPI | Keine Default | Manuell mit Middleware |
Praktische Konsequenz:
- In Django, Rails, Spring, Laravel: CSRF-Schutz ist Default — wer es nicht explizit deaktiviert, ist geschützt.
- In Express, Flask, FastAPI: Du musst aktiv eine Middleware installieren und konfigurieren. Häufiger Vergessen-Fehler.
Wenn dein Framework keinen Default hat: csrf-csrf (Express, ersetzt das deprecated csurf), Flask-WTF, oder eigene Implementierung mit Signed-Double-Submit-Pattern.
Token-Übertragung — Body, Header, beides?
Wo soll das Token mitgeschickt werden?
Body-Parameter (Form-Field):
<form method="POST">
<input type="hidden" name="csrf_token" value="...">
<!-- andere Felder -->
</form>- Klassisch für Form-basierte Apps.
- Funktioniert mit standardem
application/x-www-form-urlencodedundmultipart/form-data.
Custom Header:
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': token,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});- Klassisch für SPA-/JSON-API-Workflows.
- Custom-Header ist als zusätzliches Anti-CSRF-Signal — Browser löst CORS-Preflight aus, was Cross-Site-Anfragen ohne CORS-Allowance verhindert.
URL-Query-Parameter — VERMEIDEN:
Token in URL landet in:
- Server-Logs.
- Browser-History.
- Referrer-Header bei weiteren Klicks.
- Geräte-Synchronisation.
→ Tokens nicht in URLs.
Mehrere Übertragungs-Wege erlauben:
Manche Frameworks akzeptieren Tokens entweder im Body oder im Header. Das macht den Code flexibel — sowohl traditionelle Forms als auch SPA-Aufrufe funktionieren.
Häufige Implementierungs-Fehler
Fehler 1 — Token in URL-Query-String. Wie oben: landet in Logs und History.
Fehler 2 — Token in GET-Antwort nicht regenerieren.
Wenn das Token bei jedem GET die gleiche Sequenz hat, ist es eventuell erratbar oder veraltet.
Fehler 3 — Token in HTTPS gesetzt, aber via HTTP zurückgesendet.
Secure-Cookie-Flag nicht vergessen. HTTPS überall.
Fehler 4 — Token-Check übersehen für bestimmte HTTP-Methoden. Häufiger Bug: nur POST-Endpunkte werden geprüft, PUT/DELETE/PATCH bleiben offen. Alle zustandsändernden Methoden müssen geprüft werden.
Fehler 5 — Token-Vergleich mit == statt constant-time-compare.
Theoretisch ist String-Vergleich mit Frühabbruch ein Timing-Side-Channel. Bei modernen Tokens (256 Bit zufällig) praktisch irrelevant — aber secrets.compare_digest (Python) oder crypto.timingSafeEqual (Node) sind sauberer.
Fehler 6 — Token bei Login nicht rotieren. Wenn ein Angreifer das Token einer noch-nicht-eingeloggten Session kennt (z. B. von einer öffentlich erreichbaren Seite), bleibt es nach dem Login gültig. Bei Login Token rotieren.
Fehler 7 — Token für AJAX-Calls nicht gesendet. Klassischer Bug: Form-Endpunkte sind geschützt, aber AJAX-Endpunkte vergessen. Pro Endpoint prüfen.
Fehler 8 — Token-Validierung-Bypass durch leeren Token-Header.
Wenn die Middleware Header == StoredToken prüft, und beide leer sind, passiert es: '' == ''. Manche Pakete haben das gefixt; explizit prüfen, dass Token nicht leer ist.
CSRF-Token-Lebensdauer
Wie lange soll ein Token gültig sein?
Pro Session-Token (Default in vielen Frameworks):
- Eine Token-Wert pro Session.
- Lebt so lange wie die Session.
- Einfach, funktioniert für die meisten Apps.
Pro Form / pro Request:
- Jede Form bekommt eigenes Token.
- Token wird nach Verwendung invalidiert.
- Schutz gegen Token-Replay.
- Höherer Server-State-Overhead.
- Kann bei vielen parallelen Tabs Probleme machen.
Zeit-begrenzt:
- Token hat Verfallsdatum (z. B. 1 Stunde).
- Erzwingt regelmäßige Erneuerung.
- Reduziert Schaden bei Token-Leak.
In der Praxis ist pro-Session die häufigste Wahl. Wer sehr sensitive Aktionen hat (Banken, Krypto-Wallets), nutzt pro-Form mit kurzer Lebensdauer.
Interessantes
Synchronizer-Token vs. Double-Submit — Trade-off-Übersicht
Synchronizer ist sicherer (Server-State, kryptografische Bindung an Session). Double-Submit ist skalierbarer (stateless, gut für horizontale Architekturen). Wer beides braucht: Signed Double-Submit mit HMAC kombiniert die Vorteile.
csurf in Express ist deprecated
Das beliebte csurf-Paket für Express wurde Ende 2022 als deprecated markiert. Aktuelle Empfehlung: csrf-csrf (Signed Double-Submit) oder eigene Implementation. Wer noch csurf in Produktion hat: Migration einplanen.
Spring Security 6 hat CSRF-Defaults strikter gemacht
Spring Security 6 (2022) hat das CSRF-Verhalten gestrafft — manche legacy-Konfigurationen brechen ohne explizites Opt-In. Wer migriert: HttpSecurity.csrf()-Konfiguration prüfen.
Django CSRF + SPA = Aufmerksamkeit
Wer ein Django-Backend mit React/Vue-Frontend nutzt, muss das CSRF-Token aus dem Cookie lesen und in den AJAX-Header setzen. Django setzt das Cookie standardmäßig HttpOnly: false, damit JS es lesen kann. Wer das auf HttpOnly: true setzt, bricht den Workflow.
JSON-APIs ohne Cookie-Auth — kein CSRF-Token nötig
Wenn deine API Bearer-Tokens (JWT) per Authorization-Header nutzt und keine Cookies für Auth verwendet, hast du strukturell kein CSRF-Risiko. Browser sendet den Authorization-Header nicht automatisch cross-site. CSRF-Tokens wären redundant.
GraphQL-CSRF — separates Thema
GraphQL-Endpunkte sind typisch POST mit JSON-Body und Custom-Content-Type (application/json) — was klassisches CSRF erschwert. Wer Apollo Server mit Cookies-Auth nutzt, muss trotzdem CSRF-Token zusätzlich. Apollo hat in v3 standardmäßig CSRF prevention aktiviert.
CSRF-Token bei File-Uploads
multipart/form-data-Uploads tragen das Token typisch als versteckten Form-Field mit. Achten: das Token muss vor der Datei im Form-Body kommen, sonst kann der Parser-Workflow es übersehen. Frameworks handhaben das meist korrekt.
Weiterführende Ressourcen
Externe Quellen
- OWASP CSRF Prevention Cheat Sheet
- Django — CSRF Protection
- Rails Security Guide — CSRF
- Spring Security — CSRF
- Laravel — CSRF Protection
- csrf-csrf (Express, Signed Double-Submit)
- Flask-WTF — CSRF Protection
- ASP.NET Core — Anti-Forgery