JWT-Sicherheitsfallen: Was bei Tokens oft schiefgeht
OWASP
0x00
Security · JWT · API-Authentifizierung · PHP
JWT-Sicherheitsfallen: Was bei Tokens oft schiefgeht
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.

14 Min. Lesezeit JWT · OWASP API Security · PHP 8.4 firebase/php-jwt · lcobucci/jwt

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.

11. FAQ: JWT-Sicherheitsfallen

1Was ist die alg:none-Schwachstelle bei JWT?
Schlecht implementierte Validatoren übernehmen den Algorithmus aus dem Token und akzeptieren dann unsignierte Tokens. Eine feste Algorithmus-Allowlist im Server-Code verhindert das zuverlässig.
2Ist ein JWT verschlüsselt?
Nein, nur Base64URL-kodiert und signiert. Payload ist für jeden lesbar, der den Token sieht. Für Vertraulichkeit ist ein JWE nötig oder sensible Daten gehören gar nicht in den Payload.
3Was ist eine Algorithm-Confusion-Attacke?
Ein öffentlicher RSA-Schlüssel wird als HMAC-Secret missbraucht, wenn der Server den Algorithmus dynamisch aus dem Token übernimmt statt ihn fest vorzugeben.
4Wie lange sollte ein JWT Access Token gültig sein?
Fünf bis fünfzehn Minuten, kombiniert mit einem separaten, rotierenden Refresh Token für längere Sitzungen.
5Was ist Refresh Token Rotation?
Jede Token-Erneuerung erzeugt einen neuen Refresh Token und invalidiert den alten. Wiederverwendung eines invalidierten Tokens löst den Widerruf der gesamten Token-Familie aus.
6Warum sind zustandslose JWTs schwer zu widerrufen?
Gültigkeit wird ohne Datenbankabfrage geprüft. Sofortiger Widerruf braucht zusätzliche Mechanismen wie jti-Denylist oder Token-Versionsstempel.
7Sollte ich JWTs in localStorage speichern?
Nicht empfohlen wegen XSS-Zugriff per JavaScript. httpOnly-Cookie mit Secure/SameSite für den Refresh Token ist sicherer.
8Welche PHP-Bibliotheken eignen sich für sichere JWT-Validierung?
firebase/php-jwt und lcobucci/jwt, jeweils mit expliziter Algorithmus-Angabe bei jedem Decode-Aufruf.
9Was gehört niemals in den JWT-Payload?
Passwörter, Passwort-Hashes, API-Keys, interne Systemgeheimnisse. Nur minimale, nicht-sensible Claims verwenden.
10Ist JWT für Magento-APIs grundsätzlich geeignet?
Ja, sofern Algorithmus-Pinning, kurze Laufzeiten, Refresh-Rotation und sichere Client-Speicherung konsequent umgesetzt werden.