Europe/Berlin
--:--:--

Warum dein JWT-Setup im Backend trotz Library unsicher ist

March 17, 2026Deine JWT-Library ist korrekt implementiert. Trotzdem ist dein Setup unsicher. Ich weiß -- das klingt provokant. Du hast jsonwebtoken oder PyJWT installiert, die Doku gelesen, Tokens werden generiert und validiert. Tests sind grün. Ship it. Aber hier ist die Sache: Die Library selbst ist selten das Problem. Das Problem ist, was du drumherum nicht konfigurierst. Und genau das sehe ich immer wieder -- sowohl wenn ich Backends entwickle als auch wenn ich sie als Pentester auseinandernehme. In diesem Artikel zeige ich dir die fünf häufigsten JWT-Fehler, die ich in Audits finde. Für jeden Fehler bekommst du zuerst die Angreifer-Perspektive (wie ich das ausnutze) und dann den Fix (wie du es richtig machst). Denn genau das ist der Punkt: Wenn du nur die Entwickler-Brille aufsetzt, siehst du ein funktionierendes Token. Wenn du die Angreifer-Brille aufsetzt, siehst du eine Angriffsfläche.
Der JWT-Standard definiert einen Header, der den verwendeten Algorithmus festlegt. Ein normaler Token sieht so aus:
Json
{
  "alg": "HS256",
  "typ": "JWT"
}
Der alg:none-Angriff ist brutal simpel: Der Angreifer ändert den Header auf "alg": "none", entfernt die Signatur -- und wenn dein Backend den Algorithmus nicht explizit erzwingt, akzeptiert es das Token einfach so. Ohne jede Prüfung. So baut ein Angreifer das in Python:
Python
import base64
import json

# Header: Algorithmus auf "none" setzen
header = base64.urlsafe_b64encode(
    json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b"=")

# Payload: Admin-Rechte geben wir uns selbst
payload = base64.urlsafe_b64encode(
    json.dumps({"sub": "1", "role": "admin", "exp": 9999999999}).encode()
).rstrip(b"=")

# Signatur: einfach leer lassen
forged_token = f"{header.decode()}.{payload.decode()}."
print(forged_token)
Das war's. Kein Secret nötig, keine Kryptografie. Wenn dein Backend diesen Token akzeptiert, hat der Angreifer Admin-Zugriff. Erzwinge den Algorithmus bei der Validierung immer explizit. Verlass dich nie auf den alg-Wert im Token selbst -- der kommt vom Client und ist damit Angreifer-kontrolliert. Node.js (jsonwebtoken):
Javascript
const jwt = require('jsonwebtoken');

// FALSCH: Algorithmus wird aus dem Token gelesen
const decoded = jwt.verify(token, secret);

// RICHTIG: Algorithmus wird serverseitig erzwungen
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256']  // NUR diesen Algorithmus akzeptieren
});
Python (PyJWT):
Python
import jwt

# FALSCH
decoded = jwt.decode(token, secret, algorithms=["HS256", "none"])

# RICHTIG
decoded = jwt.decode(token, secret, algorithms=["HS256"])
Aktuelle Versionen von PyJWT und jsonwebtoken lehnen alg:none standardmäßig ab. Aber: Ältere Versionen tun das nicht, und manche Entwickler deaktivieren die Prüfung bewusst für "Entwicklungszwecke" und vergessen, sie wieder zu aktivieren. Ich habe das in Produktionssystemen gesehen. Mehr als einmal.
Wenn du HS256 mit einem schwachen Secret verwendest -- sagen wir "secret", "password123" oder den Firmennamen -- dann kann ein Angreifer das Secret per Brute-Force knacken. Tools wie hashcat oder jwt-cracker machen das automatisch:
Bash
# hashcat im JWT-Modus
hashcat -a 0 -m 16500 jwt_token.txt wordlist.txt

# Oder mit dem spezialisierten Tool
jwt-cracker -t eyJhbGciOiJIUzI1NiIs... -d wordlist.txt
Sobald das Secret bekannt ist, kann der Angreifer beliebige gültige Tokens erstellen. Game over. Aber es wird noch schlimmer: Es gibt den sogenannten Key Confusion Attack. Wenn dein Backend sowohl HS256 (symmetrisch) als auch RS256 (asymmetrisch) akzeptiert, kann ein Angreifer den öffentlichen RSA-Schlüssel (der per Design öffentlich ist) als HS256-Secret verwenden. Die Library verifiziert das Token mit dem Public Key als HMAC-Secret -- und es passt. Option A: Starkes Secret bei HS256
Javascript
const crypto = require('crypto');

// Generiere ein kryptografisch sicheres Secret
// Mindestens 256 Bit (32 Bytes) für HS256
const secret = crypto.randomBytes(64).toString('hex');
// Ergebnis: "a3f8b2c1d4e5..." (128 Zeichen Hex)
Option B (empfohlen): Asymmetrische Schlüssel (RS256/ES256)
Javascript
const jwt = require('jsonwebtoken');
const fs = require('fs');

const privateKey = fs.readFileSync('./keys/private.pem');
const publicKey = fs.readFileSync('./keys/public.pem');

// Signieren mit Private Key
const token = jwt.sign({ sub: userId, role: 'user' }, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m'
});

// Verifizieren mit Public Key
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // Algorithmus erzwingen!
});
Der Vorteil von asymmetrischen Schlüsseln: Der Private Key verlässt nie den Auth-Server. Alle anderen Services brauchen nur den Public Key zum Verifizieren -- und selbst wenn der kompromittiert wird, kann damit niemand neue Tokens erstellen.
Ich sehe regelmäßig JWTs mit einer exp-Claim von 30 Tagen, 90 Tagen -- oder gar keiner. Das bedeutet: Wenn ein Angreifer einmal ein Token abfängt (XSS, Log-Leak, Man-in-the-Middle, oder einfach aus dem Browser-Storage eines ungesperrten Laptops), hat er wochen- oder monatelang Zugriff.
Python
import jwt
import base64
import json

# Token aus dem LocalStorage der Zielanwendung extrahiert
stolen_token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzc3NTk5NjAwfQ.xxxxx"

# Wann läuft das ab?
payload = json.loads(
    base64.urlsafe_b64decode(stolen_token.split('.')[1] + '==')
)
from datetime import datetime
print(f"Expires: {datetime.fromtimestamp(payload['exp'])}")
# Ausgabe: Expires: 2026-06-28 12:00:00
# ...drei Monate. Wunderbar (für den Angreifer).
Ohne Expiration ist das Token für immer gültig -- es gibt keinen eingebauten Mechanismus, es zu invalidieren, weil JWTs stateless sind.
Javascript
const jwt = require('jsonwebtoken');

// Access Token: kurze Laufzeit (15 Minuten)
const accessToken = jwt.sign(
  { sub: userId, role: userRole },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m'    // 15 Minuten, nicht 15 Tage
  }
);

// Refresh Token: längere Laufzeit, aber in der DB gespeichert
// und damit widerrufbar
const refreshToken = jwt.sign(
  { sub: userId, type: 'refresh' },
  refreshPrivateKey,
  {
    algorithm: 'RS256',
    expiresIn: '7d'
  }
);
Die goldene Regel: Access Tokens kurzlebig (5-15 Minuten), Refresh Tokens länger aber widerrufbar. Wenn du ein Token nicht invalidieren kannst, muss es so kurzlebig sein, dass der Schaden begrenzt bleibt. Bonus-Tipp: Prüfe bei der Validierung immer die exp-Claim. Die meisten Libraries tun das standardmäßig, aber stelle sicher, dass du es nicht versehentlich deaktivierst:
Python
import jwt

# FALSCH: Expiration-Prüfung deaktiviert
decoded = jwt.decode(token, secret, algorithms=["HS256"],
                     options={"verify_exp": False})  # NIE in Produktion

# RICHTIG: Standard-Verhalten beibehalten
decoded = jwt.decode(token, secret, algorithms=["HS256"])
# Wirft jwt.ExpiredSignatureError wenn abgelaufen

Stell dir vor, du betreibst zwei Services: einen internen Admin-Service und eine öffentliche API. Beide verwenden JWTs, beide verwenden denselben Signing Key (kommt häufiger vor, als du denkst -- besonders in Microservice-Setups mit einem Shared Secret). Ein normaler User hat ein gültiges Token für die öffentliche API. Wenn der Admin-Service weder iss (Issuer) noch aud (Audience) prüft, akzeptiert er das API-Token genauso -- und der User hat plötzlich Admin-Zugriff.
Python
# Token für die öffentliche API (völlig legitim)
api_token_payload = {
    "sub": "user-123",
    "role": "user",
    "iss": "public-api",
    "aud": "public-api",
    "exp": 1742400000
}

# Der Admin-Service prüft iss/aud nicht?
# Dann akzeptiert er dieses Token auch. Privilege Escalation.
Das ist kein theoretisches Szenario. In Pentests finde ich das regelmäßig in Microservice-Architekturen, bei denen jemand "der Einfachheit halber" ein Shared Secret verwendet hat.
Javascript
// Beim Erstellen: Issuer und Audience setzen
const token = jwt.sign(
  { sub: userId, role: 'admin' },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'admin-service',
    audience: 'admin-dashboard'
  }
);

// Beim Validieren: Issuer und Audience erzwingen
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'admin-service',
  audience: 'admin-dashboard'
});
// Token von "public-api" wird jetzt abgelehnt
Python
# Python-Equivalent
decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    audience="admin-dashboard",
    issuer="admin-service"
)
Jeder Service sollte nur Tokens akzeptieren, die explizit für ihn ausgestellt wurden. Klingt offensichtlich -- wird aber oft vergessen.
Das ist der Fehler, bei dem ich mir als Pentester die Hände reibe. Entwickler packen sensible Daten in den JWT-Payload und denken, dass sie "verschlüsselt" sind, weil der Token wie eine unleserliche Zeichenkette aussieht. Spoiler: JWT-Payloads sind Base64-kodiert, nicht verschlüsselt. Jeder kann sie lesen. Ohne Key, ohne Tools, einfach so.
Python
import base64
import json

token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZW1haWwiOiJhZG1pbkBjb21wYW55LmNvbSIsInBhc3N3b3JkX2hhc2giOiIkMmIkMTIkYWJjZGVmIiwiaW50ZXJuYWxfaXAiOiIxMC4wLjEuNTAiLCJkYl9yb2xlIjoic3VwZXJ1c2VyIn0.xxxxx"

# Payload decodieren -- kein Key nötig!
payload_b64 = token.split('.')[1]
# Base64-Padding hinzufügen
payload_b64 += '=' * (4 - len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))

print(json.dumps(payload, indent=2))
Ausgabe:
Json
{
  "sub": "1234",
  "email": "admin@company.com",
  "password_hash": "$2b$12$abcdef",
  "internal_ip": "10.0.1.50",
  "db_role": "superuser"
}
E-Mail, Passwort-Hash, interne IP, Datenbank-Rolle -- alles im Klartext. Ich muss den Token nicht einmal stehlen; wenn er über eine unsichere Verbindung übertragen wird oder in einem Log auftaucht, habe ich einen kompletten Einblick in die interne Infrastruktur. Du kannst das selbst testen: Geh auf jwt.io, paste einen beliebigen JWT ein, und du siehst den Payload sofort. Kein Secret nötig für das Lesen -- nur für das Verifizieren der Signatur. Packe nur das Nötigste in den JWT-Payload:
Javascript
// FALSCH: Sensible Daten im Token
const badToken = jwt.sign({
  sub: userId,
  email: user.email,
  passwordHash: user.passwordHash,    // Niemals!
  internalIp: '10.0.1.50',           // Niemals!
  creditCard: user.creditCardLast4,   // Niemals!
  dbRole: 'superuser'                 // Niemals!
}, privateKey, { algorithm: 'RS256' });

// RICHTIG: Nur IDs und Rollen, alles andere aus der DB laden
const goodToken = jwt.sign({
  sub: userId,
  role: 'admin',
  jti: crypto.randomUUID()  // Unique Token ID für Revocation
}, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m',
  issuer: 'auth-service',
  audience: 'api'
});
Die Faustregel: Behandle den JWT-Payload wie einen öffentlichen Aushang. Alles, was drinsteht, kann jeder lesen. Wenn du verschlüsselte Tokens brauchst, schau dir JWE (JSON Web Encryption) an -- aber in den meisten Fällen ist die bessere Lösung, einfach keine sensiblen Daten ins Token zu packen.
JWTs sind nicht die Antwort auf alles. Hier sind Situationen, in denen klassische Session-basierte Authentifizierung die bessere Wahl ist:
  • Du brauchst sofortige Invalidierung. Wenn ein User sein Passwort ändert oder ein Admin einen Account sperrt, muss der Zugriff sofort enden. JWTs sind stateless -- du kannst ein ausgegebenes Token nicht zurückrufen, ohne eine Blacklist zu führen. Und eine Blacklist ist im Grunde... eine Session-Datenbank.
  • Deine Anwendung ist ein klassischer Monolith. Wenn du einen einzelnen Server hast, bringt JWT keinen Vorteil gegenüber Server-side Sessions. Sessions sind einfacher, sicherer (kein Token-Diebstahl aus dem Browser-Storage), und du bekommst Invalidierung gratis.
  • Du speicherst JWTs im LocalStorage. LocalStorage ist für jeden JavaScript-Code auf der Seite zugänglich. Ein einziger XSS-Vulnerability -- und der Angreifer hat das Token. HttpOnly Cookies sind die sicherere Wahl für browser-basierte Anwendungen.
Javascript
// Wenn du schon JWTs im Browser brauchst:
// HttpOnly Cookie statt LocalStorage
res.cookie('access_token', token, {
  httpOnly: true,    // Kein Zugriff via JavaScript
  secure: true,      // Nur über HTTPS
  sameSite: 'strict', // CSRF-Schutz
  maxAge: 15 * 60 * 1000  // 15 Minuten
});
JWTs glänzen in Microservice-Architekturen, bei Service-to-Service-Kommunikation und in Systemen, bei denen mehrere Services die Authentizität eines Tokens prüfen müssen, ohne den Auth-Server zu kontaktieren. Für alles andere sind Sessions oft die einfachere und sicherere Wahl.
Bevor du dein nächstes JWT-Setup in Produktion bringst, geh diese Liste durch:
  • [ ] Algorithmus wird serverseitig erzwungen (algorithms: ['RS256'])
  • [ ] alg: none wird explizit abgelehnt
  • [ ] Secret ist kryptografisch zufällig und mindestens 256 Bit lang (oder asymmetrische Keys)
  • [ ] Access Tokens haben eine kurze Laufzeit (5-15 Minuten)
  • [ ] Refresh Tokens sind in der Datenbank gespeichert und widerrufbar
  • [ ] iss und aud Claims werden gesetzt und validiert
  • [ ] Keine sensiblen Daten im Payload (keine E-Mails, Hashes, interne IPs)
  • [ ] Tokens werden in HttpOnly Cookies gespeichert, nicht im LocalStorage
  • [ ] HTTPS ist Pflicht (kein Token über unverschlüsselte Verbindungen)
  • [ ] Es gibt einen Plan für Key Rotation

Ein Entwickler sieht das Token. Ein Pentester sieht die Angriffsfläche. Ein Full-Cycle Security Engineer sieht beides. Die meisten JWT-Schwachstellen, die ich in Audits finde, sind keine Library-Bugs. Es sind Konfigurationsfehler und falsche Annahmen. Die Library macht genau das, was du ihr sagst -- und wenn du ihr nicht sagst, dass sie den Algorithmus erzwingen, die Expiration prüfen und den Issuer validieren soll, dann tut sie es eben nicht. Die gute Nachricht: Alle fünf Fehler in diesem Artikel sind in wenigen Minuten behoben. Die schlechte Nachricht: Wenn du sie nicht behebst, findet sie jemand anderes. Und der hat vielleicht weniger gute Absichten als ich.
Du willst wissen, ob dein JWT-Setup -- oder dein Backend generell -- einem echten Angriff standhält? Ich kombiniere Backend-Entwicklung mit Offensive Security und prüfe deine Systeme aus beiden Perspektiven. Schreib mir für ein Security Review oder einen Workshop: kontakt@buengener-software.de