Was die alg-Namen tatsächlich bedeuten
Ein JWT ist ein JWS (JSON Web Signature) mit einer JSON-Payload: header.payload.signature, jeder Teil base64url-kodiert. Der Header alg benennt das Signaturverfahren, und die zugehörige Registry ist in RFC 7518 (JSON Web Algorithms) definiert. Die vier Werte, denen du in der Praxis begegnest:
| alg | Verfahren | Schlüsseltyp | Signaturgröße |
|---|---|---|---|
HS256 | HMAC mit SHA-256 | Ein gemeinsames Geheimnis (symmetrisch) | 32 Byte |
RS256 | RSASSA-PKCS1-v1_5 mit SHA-256 | RSA-Schlüsselpaar | 256 Byte (RSA-2048) |
PS256 | RSASSA-PSS mit SHA-256 | RSA-Schlüsselpaar | 256 Byte (RSA-2048) |
ES256 | ECDSA auf P-256 mit SHA-256 | EC-Schlüsselpaar | 64 Byte (roh, r‖s) |
In dieser Tabelle stecken zwei strukturelle Punkte. Erstens ist HS256 grundlegend anders: Es ist symmetrisch, also kann jede Partei, die ein Token prüfen kann, auch selbst eines ausstellen. Es passt nur, wenn Aussteller und Verifizierer derselbe Dienst sind. RS256, PS256 und ES256 sind asymmetrisch: Verifizierer besitzen nur den öffentlichen Schlüssel, und den können sie bedenkenlos erhalten, etwa über einen JWKS-Endpunkt.
Zweitens legen die ES-Namen die Kurve fest: ES256 bedeutet P-256 mit SHA-256, ES384 bedeutet P-384 mit SHA-384, und ES512 bedeutet – Achtung bei den Zahlen – die 521-Bit-Kurve P-521 mit SHA-512. PS256 nutzt dasselbe RSA-Schlüsselmaterial wie RS256, aber mit dem neueren PSS-Padding; wenn dein Ökosystem es unterstützt, ist PSS der bessere RSA-Modus. (Den tiefergehenden Vergleich der Algorithmen über JWTs hinaus findest du unter ECDSA vs. RSA.)
Tokengröße: 64 Byte vs. 256 Byte Signatur
Eine ES256-Signatur besteht aus 64 Byte im Rohformat – etwa 86 Zeichen in base64url. Eine RS256-Signatur mit dem üblichen 2048-Bit-Schlüssel ist 256 Byte groß – etwa 342 Zeichen –, mit RSA-3072 sind es 384 Byte. Da Header und Payload in beiden Fällen gleich sind, spart der Wechsel RS256 → ES256 typischerweise ~250 Byte pro Token. Das zählt, wenn Tokens in Cookies (Budget: 4 KB), im Header Authorization jeder Anfrage, in QR-Codes oder im Datenverkehr von Mobil- und IoT-Geräten unterwegs sind. Auch der öffentliche ES256-Schlüssel ist deutlich kleiner, was JWKS-Dokumente kompakt hält.
Bei der Geschwindigkeit gilt das Muster aus dem Leitfaden ECDSA vs. RSA: ES256 signiert schneller, RS256 prüft schneller. Für einen Token-Aussteller, der in großer Menge signiert, ist ES256 die günstigere Seite dieser Abwägung.
Die Regel, die die meisten JWT-Desaster verhindert: den Algorithmus fest vorgeben
Der klassische Konfigurationsfehler bei JWTs besteht darin, das Token entscheiden zu lassen, wie es geprüft wird. Der Header alg ist eine Eingabe, die der Angreifer in der Hand hat – sie kommt im selben Token an, das du noch gar nicht geprüft hast. Ein Verifizierer, der alg aus dem Header liest und danach verzweigt, während ein einziges Schlüsselobjekt mehrere Algorithmusfamilien bedient, hebelt seine eigenen Garantien aus. Der bekannte Fall der Verwechslung: Ein Dienst prüft RS256-Tokens mit einem öffentlichen Schlüssel, aber seine Bibliothek akzeptiert mit demselben Schlüsselobjekt auch HS256 – und behandelt die Bytes des öffentlichen RSA-Schlüssels als HMAC-Secret. Da der öffentliche Schlüssel konstruktionsbedingt öffentlich ist, kann jeder ein passendes HS256-Tag berechnen und Tokens ausstellen, die der Dienst akzeptiert.
Die Lösung ist mechanisch und in RFC 8725 (JWT Best Current Practices) festgeschrieben:
- Prüfe mit einer expliziten Allowlist für Algorithmen – idealerweise mit einem einzigen Wert –, die serverseitig konfiguriert und nie aus dem Token abgeleitet wird.
- Binde Schlüssel an Algorithmen. Gib in einem JWKS jedem Schlüssel seine Werte für
algundkid; ein für ES256 veröffentlichter Schlüssel darf nie für etwas anderes verwendbar sein. Auch Prüfungen des Schlüsseltyps helfen: Ein EC-Schlüssel für P-256 kann RS256 prinzipbedingt nicht prüfen – ein Argument mehr für ES256 in gemischten Umgebungen. - Prüfe die Claims explizit – Aussteller, Audience, Ablauf – im selben Aufruf.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://issuer.example/.well-known/jwks.json"));
const { payload } = await jwtVerify(token, jwks, {
algorithms: ["ES256"], // fixed by the verifier, not by the token
issuer: "https://issuer.example",
audience: "api://orders",
});
import jwt
payload = jwt.decode(
token,
public_key_pem,
algorithms=["ES256"], # required parameter in modern PyJWT — for good reason
audience="api://orders",
issuer="https://issuer.example",
)
Warum „none“ nie akzeptabel ist
RFC 7518 registriert auch "alg": "none" – das „ungesicherte JWS“ (Unsecured JWS), ein Token mit leerem Signaturteil. Es existiert für den seltenen Fall, dass eine äußere Schicht die Integrität garantiert. Ein Verifizierer, der none akzeptiert, akzeptiert unsignierte Tokens: Jeder kann eine beliebige Payload zusammenstellen und vorlegen. Frühe JWT-Bibliotheken (aus der Zeit um 2015) behandelten none bekanntlich als ganz normalen Algorithmus, wodurch unsignierte Tokens die Prüfung standardmäßig bestanden. Moderne Bibliotheken lehnen diesen Wert ab, sofern er nicht ausdrücklich aktiviert ist – und mit einer Allowlist von ["ES256"] kann er nie ausgewählt werden. Wenn du eine Codebasis auditierst, ist jeder Codepfad, der none oder eine leere Allowlist erreichen kann, ein Befund.
Die ES256-Falle: JWS-Signaturen sind Raw r‖s, nicht DER
Mathematisch ist eine ECDSA-Signatur ein Paar ganzer Zahlen (r, s), und für dieses Paar gibt es zwei verbreitete Byte-Kodierungen. RFC 7518 §3.4 ist eindeutig: In einem JWS besteht die ES256-Signatur aus r und s als zwei 32-Byte-Ganzzahlen im Big-Endian-Format, aneinandergehängt – genau 64 Byte, danach base64url-kodiert. Es handelt sich nicht um die SEQUENCE in ASN.1 DER, die OpenSSL, Signature in Java und crypto.sign() in Node standardmäßig erzeugen.
Das ist eine der häufigsten Ursachen für „invalid signature“ in selbst geschriebenem JWT-Code: Das Token trägt einen DER-Blob von ~70 Byte (oder der Verifizierer übergibt eine 64-Byte-Raw-Signatur an eine API, die DER erwartet), und die Prüfung schlägt fehl, obwohl Schlüssel, Nachricht und Algorithmus allesamt stimmen. Wenn du mit dem eingebauten Krypto-Modul von Node signierst, fordere direkt das JWS-Format an:
import { createPrivateKey, sign } from "node:crypto";
const key = createPrivateKey(pem); // P-256 private key
const signingInput = Buffer.from(`${headerB64}.${payloadB64}`);
const signature = sign("sha256", signingInput, {
key,
dsaEncoding: "ieee-p1363", // raw r||s — what JWS requires (default is DER)
});
console.log(signature.length); // 64
Begegnet dir in freier Wildbahn eine Signatur in der falschen Form? Der Konverter DER ⇄ Raw wandelt in beide Richtungen um und zeigt die Werte r und s; die Details auf Byte-Ebene samt der High-S-Feinheit behandelt ECDSA-Signaturformate: DER vs. Raw. Und um ein echtes Token zu prüfen – Header, Claims und eine ES256-Prüfung mit deinem öffentlichen Schlüssel –, füge es in den JWT-Debugger ein.
Wählen – und ohne Ausfälle migrieren
Die Migration eines bestehenden RS256-Deployments auf ES256 ist eine Schlüsselrotation, keine Umstellung über Nacht:
- Veröffentliche den neuen EC-Schlüssel in deinem JWKS neben dem RSA-Schlüssel, jeweils mit korrekten Werten für
kidundalg, und gib den Gegenstellen Zeit, ihre JWKS-Caches zu aktualisieren. - Stelle ab jetzt ES256-Tokens mit dem neuen
kidaus. Verifizierer, die Schlüssel anhand vonkidauswählen und pro Schlüssel einen Algorithmus erzwingen, akzeptieren weiterhin beide Token-Generationen, jede gegen genau einen Schlüssel. - Sobald jedes RS256-Token abgelaufen ist, entferne den RSA-Schlüssel aus dem JWKS und verkleinere die Allowlist des Verifizierers wieder auf
["ES256"].
Der eine Satz, den du dir merken solltest: Der Verifizierer wählt den Algorithmus, nicht das Token. Hast du das richtig umgesetzt, wird die Entscheidung zwischen ES256 und RS256 zu dem, was sie sein sollte – einer unspektakulären technischen Abwägung zwischen Bytes und Millisekunden.