Von alg:none bis unsicherer Client-Speicherung
JSON Web Tokens gelten als moderner Standard für zustandslose Authentifizierung, doch fehlerhafte Implementierungen öffnen Angreifern Tür und Tor. Dieser Artikel zeigt die häufigsten Fallstricke wie Algorithmus-Verwirrung, ungeschützte Payload-Daten und unsichere Speicherung und liefert konkrete, geprüfte PHP-Lösungen für robuste API-Authentifizierung.
Inhaltsverzeichnis
- 1. Warum JWT-Sicherheit oft unterschätzt wird
- 2. JWT-Struktur verstehen: Was "nicht verschlüsselt" wirklich bedeutet
- 3. Die alg:none-Schwachstelle: Wenn die Signatur einfach entfällt
- 4. Algorithm Confusion: RS256 gegen HS256 verwechselt
- 5. Token-Ablauf und Refresh-Token-Rotation richtig umsetzen
- 6. Revocation: Warum zustandslose Tokens schwer zu widerrufen sind
- 7. Client-seitige Speicherung: Cookies gegen localStorage und XSS
- 8. Signaturvalidierung in PHP: Typische Implementierungsfehler
- 9. Unsichere gegen sichere JWT-Patterns im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum JWT-Sicherheit oft unterschätzt wird
JSON Web Tokens sind aus modernen APIs kaum wegzudenken: Sie ermöglichen zustandslose Authentifizierung, funktionieren über Servergrenzen hinweg und lassen sich ohne zentrale Session-Datenbank validieren. Genau diese Eigenschaften machen JWTs aber auch anfällig für eine Reihe wiederkehrender Implementierungsfehler, die in kaum einer anderen Authentifizierungstechnik so systematisch auftreten. Der OWASP API Security Top 10 widmet der Token-Verwaltung nicht ohne Grund eine eigene Kategorie: Broken Authentication gehört seit Jahren zu den am häufigsten ausgenutzten Schwachstellenklassen in produktiven APIs.
Das Kernproblem liegt selten in der JWT-Spezifikation selbst, sondern in der Art, wie Entwickler sie implementieren. Bibliotheken wie firebase/php-jwt oder lcobucci/jwt nehmen viel Komplexität ab, doch bereits kleine Konfigurationsfehler wie ein fehlender Algorithmus-Allowlist-Parameter oder ein zu lang gültiger Access Token können ein komplettes Authentifizierungssystem kompromittieren. Dieser Artikel behandelt die sechs häufigsten Fallstricke systematisch und zeigt, wie man sie in PHP-basierten APIs zuverlässig vermeidet, ohne die Vorteile von JWT als Format aufzugeben.
2. JWT-Struktur verstehen: Was "nicht verschlüsselt" wirklich bedeutet
Ein JWT besteht aus drei Base64URL-kodierten Teilen, getrennt durch Punkte: Header, Payload und Signatur. Die entscheidende Erkenntnis, die vielen Entwicklern erst nach einem Sicherheitsvorfall bewusst wird: Base64URL-Kodierung ist keine Verschlüsselung, sondern eine reine Textkodierung. Jeder, der ein JWT abfängt oder aus dem Browser-DevTools-Netzwerktab kopiert, kann Header und Payload in Sekunden dekodieren, etwa über jwt.io oder ein simples base64_decode() in PHP. Die Signatur schützt lediglich vor unbemerkter Manipulation des Inhalts, nicht vor dessen Einsicht.
Diese Verwechslung führt regelmäßig dazu, dass sensible Daten wie interne Benutzer-IDs aus Fremdsystemen, unverschlüsselte E-Mail-Adressen mit Zusatzkontext, Berechtigungsdetails oder im schlimmsten Fall Passwort-Hashes in der Payload landen. Wer tatsächlich vertrauliche Daten im Token transportieren muss, benötigt ein JWE (JSON Web Encryption) statt eines signierten JWS, oder verzichtet ganz darauf und hält den Token bewusst schlank: nur eine Subject-ID, Ablaufzeit und minimale Berechtigungs-Claims. Alles andere gehört serverseitig nachgeladen, sobald der Token validiert wurde.
// Decoded JWT payload - visible to anyone who intercepts the token
{
"sub": "user_48213",
"email": "customer@example.com",
"role": "customer",
"iat": 1752300000,
"exp": 1752303600
// WARNING: never put secrets here, e.g.:
// "internal_api_key": "sk_live_51Hxxxxx" <- readable in plain text!
// "password_reset_token": "a1b2c3..." <- readable in plain text!
// A JWT payload is base64url-encoded, NOT encrypted.
// Anyone holding the token can decode it instantly.
}
3. Die alg:none-Schwachstelle: Wenn die Signatur einfach entfällt
Die JWT-Spezifikation (RFC 7519) erlaubt den Algorithmus-Wert none im Header, ursprünglich für Anwendungsfälle gedacht, in denen die Integrität bereits auf einer anderen Ebene sichergestellt ist. In der Praxis wurde dieser Wert zu einer der bekanntesten JWT-Schwachstellen überhaupt: Setzt ein Angreifer den Header auf {"alg":"none"} und entfernt die Signatur komplett, akzeptieren schlecht implementierte Validatoren den Token trotzdem, weil sie den Algorithmus aus dem Token selbst übernehmen, statt ihn serverseitig vorzugeben. Damit lässt sich jeder beliebige Claim fälschen, etwa "role":"admin", ohne je einen gültigen Signaturschlüssel zu kennen.
Die Verteidigung dagegen ist eindeutig geregelt und darf niemals dem Client oder dem Token selbst überlassen werden: Die Anwendung muss beim Validieren explizit eine Allowlist erlaubter Algorithmen vorgeben, und none darf niemals Teil dieser Liste sein. Moderne Bibliotheken wie firebase/php-jwt ab Version 6 verlangen den Algorithmus-Parameter inzwischen zwingend bei jedem Decode-Aufruf, ältere Versionen und selbstgeschriebene Validatoren sind hier jedoch häufig die Schwachstelle. Ein Penetrationstest, der gezielt alg:none-Payloads gegen jeden Token-Validierungs-Endpunkt schickt, gehört deshalb in jedes API-Sicherheitsaudit.
4. Algorithm Confusion: RS256 gegen HS256 verwechselt
Noch subtiler als alg:none ist die Algorithm-Confusion-Attacke zwischen asymmetrischen und symmetrischen Verfahren. Viele APIs signieren Tokens mit RS256, einem asymmetrischen Verfahren mit privatem Signierschlüssel und öffentlichem Verifizierungsschlüssel. Der öffentliche Schlüssel ist bewusst öffentlich zugänglich, etwa über einen JWKS-Endpunkt oder eingebettet im Client-Code. Ein Angreifer, der diesen öffentlichen Schlüssel kennt, kann einen manipulierten Token erstellen, den Header auf HS256 ändern und den öffentlichen RSA-Schlüssel als HMAC-Geheimnis missbrauchen.
Wenn der Server ebenfalls den Algorithmus aus dem eingehenden Token liest, statt ihn fest vorzugeben, verifiziert er die HMAC-Signatur mit exakt demselben öffentlichen Schlüssel, den der Angreifer zur Fälschung verwendet hat, und akzeptiert den Token fälschlich als gültig. Diese Schwachstelle wurde in mehreren bekannten JWT-Bibliotheken dokumentiert, bevor die Hersteller die Algorithmus-Pinning-Pflicht einführten. Die einzige zuverlässige Gegenmaßnahme: Der Server darf niemals dem alg-Feld im Token vertrauen. Stattdessen muss die erwartete Algorithmus-Familie fest im Code hinterlegt sein, und für RS256-Schlüssel und HS256-Secrets sollten getrennte Schlüsselspeicher mit unterschiedlichen Zugriffsrechten verwendet werden.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Model;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;
/**
* Validates JWT access tokens with a pinned algorithm allowlist.
* The algorithm is never read from the token header alone -
* it is always paired with the matching key type.
*/
final class TokenValidator
{
/**
* @param string $publicKeyPem RSA public key in PEM format, used only for RS256
*/
public function __construct(
private readonly string $publicKeyPem
) {
}
/**
* Decodes and verifies a JWT, rejecting any token that does not
* explicitly use RS256. The "none" algorithm is never permitted.
*
* @param string $jwt Raw JWT string from the Authorization header
* @return array<string, mixed> Decoded and verified claims
* @throws SignatureInvalidException When the signature does not match
*/
public function validate(string $jwt): array
{
// Explicitly pin the algorithm - never trust the "alg" header alone.
// This single line prevents both alg:none and RS256/HS256 confusion.
$decoded = JWT::decode($jwt, new Key($this->publicKeyPem, 'RS256'));
return (array) $decoded;
}
}
5. Token-Ablauf und Refresh-Token-Rotation richtig umsetzen
Ein häufig unterschätztes Risiko ist die Gültigkeitsdauer von Access Tokens. Ein JWT ohne exp-Claim oder mit einer Gültigkeit von mehreren Tagen bleibt nach einem Diebstahl, etwa durch ein abgefangenes Netzwerkpaket oder ein XSS-Skript, so lange nutzbar, wie es der Angreifer möchte. Die etablierte Best Practice sind kurzlebige Access Tokens von fünf bis fünfzehn Minuten in Kombination mit langlebigeren Refresh Tokens, die ausschließlich zum Erneuern des Access Tokens verwendet werden und niemals direkt für API-Zugriffe dienen.
Entscheidend ist dabei die Refresh Token Rotation: Bei jeder Erneuerung wird nicht nur ein neuer Access Token ausgestellt, sondern auch ein neuer Refresh Token, während der alte serverseitig invalidiert wird. Taucht ein bereits verwendeter, eigentlich invalidierter Refresh Token erneut auf, ist das ein klares Indiz für einen gestohlenen Token, und das System sollte reflexartig die gesamte Token-Familie widerrufen, nicht nur den einzelnen Token. Diese Erkennung, bekannt als Refresh Token Reuse Detection, lässt sich mit einer einfachen Datenbanktabelle umsetzen, die pro Familie eine aktuell gültige Token-ID speichert.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Model;
use Mironsoft\Security\Api\RefreshTokenRepositoryInterface;
/**
* Rotates refresh tokens on every use and detects reuse of
* already-invalidated tokens as a sign of theft.
*/
final class RefreshTokenRotationService
{
/**
* @param RefreshTokenRepositoryInterface $repository Persists refresh token state
*/
public function __construct(
private readonly RefreshTokenRepositoryInterface $repository
) {
}
/**
* Issues a new token pair and invalidates the previous refresh token.
* If the given token was already rotated once before, the whole
* token family is revoked immediately.
*
* @param string $presentedToken Refresh token sent by the client
* @return array{access_token: string, refresh_token: string}
* @throws \RuntimeException When token reuse is detected
*/
public function rotate(string $presentedToken): array
{
$record = $this->repository->findByToken($presentedToken);
if ($record === null || $record->isRevoked()) {
// Reuse of an already-rotated token: assume theft, kill the family
$this->repository->revokeFamily($record?->getFamilyId());
throw new \RuntimeException('Refresh token reuse detected.');
}
$newRefreshToken = $this->repository->rotate($record);
$newAccessToken = $this->issueShortLivedAccessToken($record->getUserId());
return [
'access_token' => $newAccessToken,
'refresh_token' => $newRefreshToken,
];
}
/**
* Creates a short-lived access token, valid for ten minutes.
*
* @param int $userId Subject the token is issued for
* @return string Signed JWT access token
*/
private function issueShortLivedAccessToken(int $userId): string
{
// Access token TTL kept intentionally short: 10 minutes
return 'signed.jwt.token';
}
}
6. Revocation: Warum zustandslose Tokens schwer zu widerrufen sind
Der größte architektonische Widerspruch bei JWT liegt in seiner Kernidee selbst: Ein Token gilt als gültig, solange seine Signatur stimmt und er nicht abgelaufen ist, ganz ohne Datenbankabfrage. Genau das macht JWT schnell und horizontal skalierbar, aber es macht sofortigen Widerruf schwierig. Wird ein Mitarbeiter gekündigt oder ein Account kompromittiert, bleibt ein bereits ausgestellter Access Token bis zu seinem exp-Zeitpunkt technisch gültig, selbst wenn er im Backend längst als kompromittiert markiert wurde.
In der Praxis haben sich drei Strategien etabliert, die sich kombinieren lassen: Erstens möglichst kurze Access-Token-Laufzeiten, damit das Zeitfenster eines nicht widerrufbaren Tokens minimal bleibt. Zweitens eine Denylist für explizit widerrufene Token-IDs (jti-Claim), typischerweise in Redis mit einer TTL, die der verbleibenden Token-Gültigkeit entspricht, sodass die Liste nie unbegrenzt wächst. Drittens ein globaler Token-Versionsstempel pro Benutzer, der bei jeder sicherheitsrelevanten Aktion wie Passwortänderung erhöht wird; jeder Token trägt diesen Stempel als Claim, und der Server vergleicht ihn bei jeder Anfrage mit dem aktuellen Wert in der Datenbank. Reine Stateless-Puristen verzichten auf Revocation komplett und verlassen sich ausschließlich auf kurze Laufzeiten, was für viele APIs ein akzeptabler Kompromiss ist, für hochsensible Systeme aber zu riskant sein kann.
# Store a revoked token's jti (JWT ID) in Redis with a TTL matching
# the token's remaining lifetime, so the denylist self-cleans.
redis-cli SETEX "jwt:revoked:4f8a1c9e-2b3d-4e5f-9a1b-8c7d6e5f4a3b" 600 "revoked"
# Check revocation status during request validation
redis-cli EXISTS "jwt:revoked:4f8a1c9e-2b3d-4e5f-9a1b-8c7d6e5f4a3b"
7. Client-seitige Speicherung: Cookies gegen localStorage und XSS
Die häufigste Diskussion in JWT-Sicherheitsreviews dreht sich um die Frage, wo der Token im Browser gespeichert werden soll. localStorage ist bequem, da JavaScript uneingeschränkt darauf zugreifen kann, genau das ist aber auch das Problem: Jedes erfolgreiche Cross-Site-Scripting (XSS) auf der Seite, etwa über eine ungefilterte Nutzereingabe oder ein kompromittiertes Drittanbieter-Skript, kann den Token per localStorage.getItem() auslesen und an einen Angreifer-Server senden. Da JWTs in der Regel weder IP- noch Geräte-gebunden sind, funktioniert ein derart gestohlener Token von jedem beliebigen Ort aus.
Die robustere Alternative ist ein httpOnly-Cookie mit den Flags Secure und SameSite=Strict oder Lax. Der Browser verweigert JavaScript jeglichen Lesezugriff auf ein httpOnly-Cookie, wodurch klassisches token-stehlendes XSS wirkungslos wird. Der Kompromiss: Cookies sind anfällig für Cross-Site-Request-Forgery (CSRF), wenn SameSite nicht korrekt gesetzt ist, weshalb zusätzlich ein CSRF-Token oder das Double-Submit-Cookie-Pattern empfohlen wird. Für Single-Page-Applications mit eigener API-Domain hat sich das Muster bewährt, den Access Token kurzlebig im Speicher (nicht in localStorage, sondern im JavaScript-Variablenspeicher) zu halten und ausschließlich den Refresh Token in einem httpOnly-Cookie zu persistieren.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Controller\Auth;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\JsonFactory;
/**
* Issues the refresh token exclusively as an httpOnly, Secure,
* SameSite cookie. The token is never exposed to client-side JavaScript.
*/
final class Login implements HttpPostActionInterface
{
/**
* @param JsonFactory $jsonFactory Builds the JSON API response
*/
public function __construct(
private readonly JsonFactory $jsonFactory
) {
}
/**
* Authenticates the request and sets the refresh token cookie.
*
* @return \Magento\Framework\Controller\Result\Json
*/
public function execute()
{
$refreshToken = 'issued.refresh.token';
// httpOnly: not readable by JavaScript, mitigates XSS token theft
// Secure: only sent over HTTPS
// SameSite=Strict: mitigates CSRF for cross-site requests
setcookie('refresh_token', $refreshToken, [
'expires' => time() + 60 * 60 * 24 * 14,
'path' => '/api/auth',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
$result = $this->jsonFactory->create();
// Only the short-lived access token goes into the JSON body,
// held in memory on the client, never in localStorage.
return $result->setData(['access_token' => 'signed.jwt.token']);
}
}
8. Signaturvalidierung in PHP: Typische Implementierungsfehler
Abseits der konzeptionellen Fallen passieren die meisten JWT-Schwachstellen in wenigen Codezeilen der Validierungslogik. Ein klassischer Fehler: Der Signaturschlüssel wird dynamisch anhand eines Claims im Token selbst nachgeladen, etwa über ein kid-Feld (Key ID), das ungeprüft als Dateipfad oder Datenbankschlüssel verwendet wird. Ohne strikte Validierung des kid-Werts öffnet das Angriffsvektoren wie Path Traversal oder SQL Injection über den Token-Header selbst, noch bevor die Signatur überhaupt geprüft wurde.
Ein weiterer verbreiteter Fehler ist die Verwechslung von Decodierung und Verifizierung: Manche Entwickler nutzen zum schnellen Debuggen eine Funktion, die den Payload ohne Signaturprüfung dekodiert, etwa JWT::urlsafeB64Decode() direkt statt JWT::decode() mit Schlüssel, und diese Debug-Variante schafft es versehentlich in den Produktionscode. Ebenso riskant ist das Ignorieren von exp- und nbf-Claims bei selbstgeschriebenen Validatoren, oder eine zu große Toleranz (leeway) bei der Zeitprüfung, die Replay-Angriffe mit abgelaufenen Tokens erleichtert. Grundsätzlich gilt: Signaturvalidierung, Algorithmus-Pinning, Ablaufprüfung und Issuer/Audience-Prüfung gehören in eine einzige, gut getestete Klasse, nie verteilt über mehrere Controller-Methoden.
9. Unsichere gegen sichere JWT-Patterns im Vergleich
Die folgende Übersicht fasst die in diesem Artikel behandelten JWT-Fallstricke zusammen und stellt jedem unsicheren Muster die empfohlene sichere Umsetzung gegenüber.
| Bereich | Unsicheres Pattern | Sichere Alternative |
|---|---|---|
| Algorithmus | alg wird aus dem Token übernommen, none akzeptiert | Feste Algorithmus-Allowlist im Code (z. B. nur RS256) |
| Client-Speicherung | Token in localStorage, per JS auslesbar | Refresh Token in httpOnly/Secure/SameSite-Cookie |
| Gültigkeitsdauer | Access Token ohne exp oder mit Tagen Gültigkeit | 5-15 Min. Access Token + rotierender Refresh Token |
| Payload-Inhalt | Geheimnisse oder Passwort-Hashes im Payload | Nur minimale Claims, sensible Daten serverseitig |
| Widerruf | Kein Revocation-Mechanismus vorhanden | jti-Denylist in Redis oder Token-Versionsstempel |
Mironsoft
API-Sicherheit, JWT-Härtung und Authentifizierungs-Audits für Magento-Backends
JWT-Implementierung professionell absichern?
Wir prüfen eure Token-Validierung auf Algorithm-Confusion, unsichere Speicherung und fehlende Revocation-Mechanismen und setzen gehärtete, produktionsreife Authentifizierungslösungen für eure PHP- und Magento-APIs um.
JWT-Sicherheitsaudit
Prüfung von Signaturvalidierung, Algorithmus-Pinning und Claims
Refresh-Token-Architektur
Rotation, Reuse Detection und Revocation-Strategien implementieren
API-Härtung
Sichere Cookie-Konfiguration und CSRF-Schutz für PHP-APIs
10. Zusammenfassung
JWT-Sicherheit scheitert selten an der Spezifikation selbst, sondern an wiederkehrenden Implementierungsfehlern: dem Vertrauen in das alg-Feld des Tokens statt einer festen Algorithmus-Allowlist, der Verwechslung von Base64URL-Kodierung mit Verschlüsselung, zu langen Token-Laufzeiten ohne Rotation und der bequemen, aber XSS-anfälligen Speicherung in localStorage. Jede dieser Fallen lässt sich mit klaren, testbaren Regeln vermeiden: Algorithmus im Code fest verdrahten, Payload minimal halten, kurze Laufzeiten mit rotierenden Refresh Tokens kombinieren und Tokens in httpOnly-Cookies statt im JavaScript-zugänglichen Speicher ablegen.
Zustandslose Authentifizierung bleibt dabei ein bewusster Architektur-Kompromiss: Sie bringt Skalierbarkeit auf Kosten von sofortigem Widerruf. Wer diesen Kompromiss kennt und durch kurze Token-Laufzeiten, eine schlanke Denylist und einen Versionsstempel pro Nutzer abfedert, kann JWT sicher und produktionsreif in PHP- und Magento-APIs einsetzen, ohne die Performance-Vorteile zu verlieren, die JWT ursprünglich attraktiv gemacht haben.
JWT-Sicherheitsfallen - Das Wichtigste auf einen Blick
Algorithmus fest pinnen
Niemals alg aus dem Token übernehmen. alg:none und Algorithm Confusion nur so zuverlässig verhindern.
Keine Geheimnisse im Payload
JWT ist nur signiert, nicht verschlüsselt. Nur minimale, nicht-sensible Claims transportieren.
Kurze Laufzeit + Rotation
5-15 Min. Access Token, rotierender Refresh Token mit Reuse Detection.
Sichere Speicherung
httpOnly/Secure/SameSite-Cookie statt localStorage, plus jti-Denylist für Revocation.