Zum Inhalt springen
CodeAuditAgent
Alle Artikel

JWT- und Session-Fallstricke und wie Sie sie vermeiden

Die JWT- und Session-Fehler, die zur Kontoübernahme führen: Algorithmus-Verwirrung, schwache Secrets, fehlende Claim-Prüfungen, Rotation und echtes Logout.

· 7 Min. Lesezeit · Lina Source LLC

JSON Web Tokens sind ein vernünftiges Format mit einer langen Liste scharfer Kanten. Das Token selbst ist selten das Problem. Die Fehler stecken darin, wie es verifiziert wird, wo es gespeichert ist, wie lange es lebt und was passiert, wenn sich ein Nutzer abmeldet. Jeder dieser Fehler hat dasselbe Ergebnis: Jemand hält ein Token, das er nicht halten sollte, und Ihr Server akzeptiert es.

Die beiden Schwächen, die am häufigsten vorkommen, sind CWE-347, die unzureichende Verifizierung einer kryptografischen Signatur, und CWE-613, unzureichender Ablauf von Sessions. Die folgenden Beispiele verwenden jose, eine weit verbreitete JavaScript-Bibliothek für JWTs, die in Node.js, Edge-Runtimes und Browsern läuft.

Decodieren ist nicht verifizieren

Die direkteste Form von CWE-347 ist es, Claims aus einem Token zu lesen, ohne dessen Signatur zu prüfen. Jede JWT-Bibliothek hat eine decode-Funktion zum Debuggen, und sie taucht in Auth-Middleware häufiger auf, als sie sollte. Ein decodiertes Token ist nur Base64, das jeder schreiben kann. In JavaScript-Codebasen versteckt sich das Muster oft in einer kleinen Hilfsfunktion, die das Token an den Punkten aufteilt und JSON.parse auf den mittleren Teil anwendet. Sie funktioniert in jedem Test, weil die Test-Tokens gültig sind, und sie akzeptiert in der Produktion jedes gefälschte Token.

import { decodeJwt, jwtVerify } from 'jose';

// Verwundbar: Jeder kann ein Token mit beliebiger Benutzer-ID in sub erzeugen
const claims = decodeJwt(token);
const userId = claims.sub;

// Korrekt: Signatur, Algorithmus und Claims werden zuerst geprüft
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;

alg none und Algorithmus-Verwirrung

Der JWT-Header gibt an, welcher Algorithmus das Token signiert hat, und der Angreifer kontrolliert den Header. Daraus folgen zwei klassische Angriffe. Der erste ist alg mit dem Wert none: ein unsigniertes Token, das manche älteren Bibliotheken als gültig akzeptierten. Der zweite ist die Algorithmus-Verwirrung: Ein Server, der RS256 erwartet, aber den Header den Algorithmus wählen lässt, kann ein HS256-Token erhalten, das mit dem öffentlichen Schlüssel des Servers als HMAC-Secret signiert wurde. Der öffentliche Schlüssel ist öffentlich, der Angreifer kann also alles signieren.

Moderne Bibliotheken wehren beides ab; jose lehnt ungesicherte Tokens in jwtVerify ab und prüft, dass der Schlüsseltyp zum Algorithmus passt. Verlassen Sie sich trotzdem nicht allein auf Standardwerte. Legen Sie die Liste der Algorithmen bei jedem verify-Aufruf explizit fest, damit ein künftiges Refactoring oder ein Bibliothekswechsel sie nicht stillschweigend erweitert. Wenn Sie mehr als einen Algorithmus akzeptieren, etwa während einer Schlüsselmigration, listen Sie beide explizit auf und verwenden Sie für jeden einen eigenen Schlüssel. Beim Rotieren von Schlüsseln setzen Sie ein kid in den Header und wählen den Schlüssel anhand des kid aus Ihrer eigenen Liste aus, niemals über eine URL, die im Token-Header genannt wird.

Schwache Signatur-Secrets

Ein HS256-Token wird mit einem gemeinsamen Secret signiert. Ist das Secret kurz oder erratbar, etwa „secret“, der Name der Anwendung oder ein aus einem Tutorial kopierter Wert, kann ein Angreifer, der ein einziges gültiges Token besitzt, es offline mit handelsüblichen Werkzeugen per Brute Force ermitteln und anschließend Tokens für jeden beliebigen Nutzer signieren. Für das Raten im Offline-Betrieb gibt es kein Rate Limit.

  • Verwenden Sie für HS256 mindestens 32 zufällige Bytes, erzeugt etwa mit openssl rand -base64 32.
  • Laden Sie das Secret aus der Umgebung oder einem Secret-Manager und brechen Sie den Start ab, wenn es fehlt oder zu kurz ist.
  • Committen Sie es niemals, und rotieren Sie es, falls es jemals committet wurde.
  • Wenn mehrere Dienste Tokens verifizieren, aber nur einer sie ausstellen soll, verwenden Sie ein asymmetrisches Verfahren wie RS256 oder EdDSA, damit die verifizierenden Dienste nur den öffentlichen Schlüssel halten.

exp, aud und iss validieren

Eine gültige Signatur beweist nur, wer das Token ausgestellt hat. Die Claims entscheiden, ob es für Sie bestimmt und noch aktuell ist. Ein Token ohne Ablaufzeit ist für immer gültig. Ein Token, das für Ihre Mobile-API ausgestellt wurde, sollte Ihr Admin-Dienst nicht allein deshalb akzeptieren, weil beide demselben Identity Provider vertrauen. Genau dafür sind aud und iss da.

import { SignJWT, jwtVerify } from 'jose';

const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
  throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);

const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';

export function signAccessToken(userId: string, tokenVersion: number) {
  return new SignJWT({ tv: tokenVersion })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('10m')
    .sign(secret);
}

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],
    issuer: ISSUER,
    audience: AUDIENCE,
    requiredClaims: ['exp', 'sub'],
  });
  return payload;
}

jose prüft exp immer dann, wenn es vorhanden ist, aber ein Token ohne exp würde sonst durchgehen. Die Option requiredClaims schließt diese Lücke. Verifizieren Sie Tokens eines externen Identity Providers gegen dessen veröffentlichtes Schlüsselset mit createRemoteJWKSet und legen Sie Algorithmen, Issuer und Audience trotzdem fest.

localStorage oder httpOnly-Cookies

Ein Token im localStorage ist für jedes Skript auf der Seite lesbar. Ein einziger XSS-Fehler oder ein kompromittiertes Skript eines Drittanbieters genügt, und das Token kann an einen Angreifer geschickt und bis zu seinem Ablauf von überall aus verwendet werden.

Ein httpOnly-Cookie kann von JavaScript nicht gelesen werden. XSS bleibt ernst, denn eingeschleustes Skript kann Anfragen als der Nutzer stellen, solange die Seite geöffnet ist, aber es kann keine langlebigen Zugangsdaten stehlen und später erneut verwenden. Für Browser-Apps, die mit ihrem eigenen Backend sprechen, sind Cookies der bessere Standard. Für eine Single-Page-App, die eine separate API aufruft, ist es ein vernünftiger Mittelweg, das Access Token nur im Arbeitsspeicher zu halten und das Refresh Token in einem httpOnly-Cookie.

// Express: Session-Cookie mit sicheren Attributen
res.cookie('__Host-session', accessToken, {
  httpOnly: true, // nicht aus JavaScript lesbar
  secure: true, // nur über HTTPS; vom Präfix __Host- gefordert
  sameSite: 'lax', // wird bei siteübergreifenden POSTs nicht gesendet
  path: '/', // vom Präfix __Host- gefordert
  maxAge: 10 * 60 * 1000, // Millisekunden, passend zur Token-Lebensdauer
});

Das Präfix __Host- weist den Browser an, das Cookie abzulehnen, sofern es nicht Secure ist, path auf / gesetzt hat und kein Domain-Attribut besitzt. Das verhindert, dass eine kompromittierte Subdomain es überschreibt.

SameSite und was es nicht abdeckt

SameSite=Lax blockiert das Cookie bei siteübergreifenden POST-Anfragen und beim Laden von Subressourcen, was das meiste klassische CSRF beseitigt. Bei Top-Level-GET-Navigationen wird das Cookie weiterhin gesendet, sodass jeder GET-Endpunkt, der Zustand ändert, angreifbar bleibt. Strict blockiert auch diese, meldet Nutzer aber ab, wenn sie aus einer E-Mail oder von einer anderen Website einem Link zu Ihrer App folgen. None deaktiviert den Schutz und erfordert Secure.

Lax plus die Regel, dass GET-Anfragen niemals Zustand ändern, ist eine solide Ausgangsbasis. Ergänzen Sie für sensible Mutationen eine Prüfung des Origin-Headers oder ein CSRF-Token. Denken Sie daran, dass SameSite alle Subdomains Ihrer registrierbaren Domain als same-site behandelt; eine verwundbare Subdomain kann also weiterhin Anfragen fälschen.

Rotation, Widerruf und echtes Logout

Ein zustandsloses JWT lässt sich vor seinem Ablauf nicht widerrufen; das ist der Preis dafür, bei jeder Anfrage keine Datenbank zu befragen. CWE-613 beschreibt, was passiert, wenn dieser Kompromiss ignoriert wird: Logout, Passwortänderungen und Kontosperrungen, die den Zugriff nicht wirklich beenden. Auch das Löschen eines Kontos, das Ändern einer Rolle und das Entfernen einer Person aus einem Team sind Widerrufsereignisse, und jedes davon sollte bei der nächsten Anfrage wirken, nicht erst beim nächsten Ablauf eines Tokens.

  • Halten Sie Access Tokens kurzlebig, im Bereich von Minuten, nicht von Tagen.
  • Speichern Sie Refresh Tokens serverseitig als Hash und rotieren Sie sie bei jeder Verwendung.
  • Wird ein bereits rotiertes Refresh Token erneut verwendet, behandeln Sie das als Diebstahl und widerrufen Sie die gesamte Token-Familie.
  • Führen Sie eine Token-Version in der Benutzerzeile und im Token; erhöhen Sie sie beim Abmelden auf allen Geräten, bei einer Passwortänderung oder bei einer Sperrung.
  • Erzeugen Sie die Session-Kennung bei der Anmeldung neu, um Session Fixation zu verhindern.
export async function requireUser(token: string) {
  const payload = await verifyAccessToken(token);

  const user = await db.user.findUnique({
    where: { id: payload.sub },
    select: { id: true, tokenVersion: true, disabled: true },
  });

  // Eine erhöhte Version oder ein deaktiviertes Konto beendet den Zugriff sofort
  if (!user || user.disabled || user.tokenVersion !== payload.tv) {
    throw new Error('Session revoked');
  }
  return user;
}

Diese Abfrage bringt einen Datenbank-Lesezugriff pro Anfrage zurück, und das sind die ehrlichen Kosten des Widerrufs. Viele Anwendungen fahren mit einer opaken Session-ID in einem Cookie und einer Sessions-Tabelle einfacher und sicherer. Logout bedeutet dann, eine Zeile zu löschen. Setzen Sie JWTs dort ein, wo ihre Eigenschaften tatsächlich helfen, etwa für kurzlebige Tokens zwischen Diensten, und nicht, weil sie in einem Tutorial der Standard sind.

Wofür Sie sich auch entscheiden: Das Logout muss auf dem Server stattfinden. Das Cookie zu löschen oder das Token im Browser zu entfernen beseitigt nur die Kopie des Nutzers; eine gestohlene Kopie funktioniert weiter, bis der Server sie ablehnt. Löschen Sie beim Logout die serverseitige Session oder das Refresh Token, löschen Sie das Cookie mit demselben Namen, demselben Pfad und denselben Attributen, mit denen es gesetzt wurde, und erhöhen Sie die Token-Version, wenn der Nutzer sich überall abmelden wollte. Ein Passwort-Reset sollte ebenfalls jede andere Session beenden.

Eine kurze Review-Checkliste

  • Suchen Sie in Authentifizierungspfaden nach decode-Aufrufen.
  • Bestätigen Sie, dass jeder verify-Aufruf Algorithmen, Issuer und Audience festlegt und exp verlangt.
  • Prüfen Sie, wie das Signatur-Secret erzeugt, geladen und beim Start validiert wird.
  • Finden Sie heraus, wo Tokens im Browser gespeichert werden.
  • Testen Sie, dass Logout, Passwortänderung und Kontosperrung bestehende Sessions beenden.

Bei diesen Prüfungen geht es vor allem darum, Codepfade von Anfang bis Ende zu lesen, und genau so geht CodeAuditAgent ein Audit an: Befunde kommen mit der zitierten Zeile, der CWE, einem Exploit-Szenario und einem Patch, sodass sich eine fehlende Audience-Prüfung in Minuten bestätigen und beheben lässt.