Kurz gesagt. Signiere mit ES256, wenn Tokens deine Kontrolle verlassen: Jede JOSE-Bibliothek, die wir getestet haben, prüft es, alle großen Cloud-KMS und HSMs können den Schlüssel verwahren, und Standards wie FAPI 2.0 führen es auf. Wähle Ed25519, wenn alle Verifizierer in deiner Hand sind und du den Schlüssel in Software oder in AWS KMS halten kannst: Seine Signaturen sind deterministisch und lassen sich nicht in eine zweite gültige Form umwandeln, was zwei Klassen von ECDSA-Fehlern ausschließt. Wenn du Ed25519 wählst, veröffentliche es vorerst als "alg": "EdDSA" und akzeptiere beim Prüfen sowohl EdDSA als auch den neuen Namen Ed25519. Weniger als die Hälfte der getesteten Bibliotheken versteht den neuen Namen bisher.
Alle Tokens und Schlüssel auf dieser Seite wurden eigens für sie erzeugt. Die Ergebnisse zu den Bibliotheken stammen aus Läufen in offiziellen Docker-Images am 20. Oktober 2026; die Versionen stehen jeweils beim Ergebnis.
Was die beiden Namen bedeuten
| ES256 | EdDSA / Ed25519 | |
|---|---|---|
| Verfahren | ECDSA auf P-256 mit SHA-256 | EdDSA auf Edwards25519 (RFC 8032 §5.1) |
| Definition in JOSE | RFC 7518 §3.4 | RFC 8037, umbenannt durch RFC 9864 |
| Status in der IANA-Registry für JOSE | Recommended+ | EdDSA: Deprecated; Ed25519: Optional |
| JWK | "kty": "EC", crv, x, y | "kty": "OKP", "crv": "Ed25519", x |
| Signatur | r ‖ s, 64 Byte | R ‖ S, 64 Byte |
| Zugelassen in FIPS 186-5 | ja | ja |
Die Namensänderung ist wichtiger, als sie aussieht. RFC 8037 hat einen einzigen Wert EdDSA für Ed25519 und Ed448 registriert, daher sagt der Wert alg allein nicht, welche Kurve zu verwenden ist; das legt das Feld crv des Schlüssels fest. RFC 9864 (Oktober 2025) hat diesen polymorphen Namen als veraltet eingestuft und die vollständig spezifizierten Namen Ed25519 und Ed448 registriert. Das Schlüsselformat ändert sich nicht, nur der String in alg. ES256 war in JOSE bereits vollständig spezifiziert und braucht keine Umbenennung. In COSE hat RFC 9864 dasselbe mit ECDSA gemacht: ES256 (-7) ist zugunsten von ESP256 (-9) veraltet, was für WebAuthn und COSE-basierte Credentials relevant ist, nicht für JWT.
Größe: identisch, wo es zählt
Beide Signaturen sind 64 Byte groß, also 86 Zeichen in base64url. Die einzigen Unterschiede sind der Header-String und der öffentliche Schlüssel:
ES256 token 253 chars, signature 64 bytes (86 chars)
EdDSA token 253 chars, signature 64 bytes (86 chars)
Ed25519 token 256 chars, signature 64 bytes (86 chars)
es public JWK 137 chars: {"kty":"EC","x":"B8UtJ0UT7zKuYn6NgB7h0E-L1-UF85FC-aoxjO7a2AE","y":"gwHfmffA6Vv9eWTKD_XCwGoLj5JtI1DQtGVq27icAU4","crv":"P-256","kid":"es"}
ed public JWK 90 chars: {"kty":"OKP","crv":"Ed25519","x":"NzIuH6R_919ooSpy2wU9iMMhGjaS3yXnjLF0AiqAC04","kid":"ed"}
Ein öffentlicher Ed25519-Schlüssel ist 32 Byte groß, ein P-256-Schlüssel besteht aus zwei 32-Byte-Koordinaten, daher schrumpft ein JWKS um etwa 47 Zeichen pro Schlüssel. Davon sollte niemand die Wahl des Algorithmus abhängig machen. Gegenüber RS256 sieht es anders aus: Eine RSA-2048-Signatur ist 256 Byte groß, weshalb beide Kurven bei Tokens in Cookies und Headern klar vorn liegen (siehe ES256 vs. RS256).
Determinismus: wo ECDSA scheitern kann und EdDSA nicht
ECDSA braucht für jede Signatur eine frische, geheime Zahl. Wird sie ein einziges Mal wiederverwendet oder ist sie auch nur leicht vorhersagbar, lässt sich der private Schlüssel aus öffentlichen Signaturen berechnen; ECDSA-Nonce-Wiederverwendung zeigt die Algebra und die realen Vorfälle. Die meisten JOSE-Bibliotheken beziehen diese Zahl aus dem Zufallsgenerator des Systems, sodass dieselbe Eingabe jedes Mal eine andere Signatur ergibt. RFC 6979 definiert einen deterministischen Weg, sie abzuleiten, aber ob deine Bibliothek, dein HSM oder dein KMS ihn nutzt, liegt nicht in deiner Hand.
EdDSA leitet den Wert für jede Signatur konstruktionsbedingt aus dem privaten Schlüssel und der Nachricht ab (RFC 8032 §5.1.6). Gleicher Schlüssel, gleiche Nachricht, gleiche Signatur – ganz ohne Zufallsgenerator. Damit entfällt die Fehlerursache hinter den Schlüssellecks bei der PlayStation 3 und bei Android-Wallets im Jahr 2013. Nicht jedes Risiko ist damit beseitigt: Deterministisches Signieren ist anfälliger für Fehlerinjektion auf Hardware, an die ein Angreifer physisch herankommt, weshalb manche Neuentwicklungen wieder Zufall hinzufügen. Für JWT-Aussteller, die auf Servern laufen, ist Determinismus ein klarer Gewinn.
Malleability: ein zweites gültiges Token gratis
Eine ECDSA-Signatur (r, s) hat einen Zwilling: (r, n − s) wird mit demselben Schlüssel und derselben Nachricht als gültig erkannt, wobei n die Ordnung der Kurve ist. JOSE-Bibliotheken lehnen den Zwilling nicht ab. Ed25519 nach RFC 8032 verlangt vom Verifizierer, jedes S abzulehnen, das nicht kleiner als die Gruppenordnung L ist, deshalb ergibt das Addieren von L zu S keine zweite gültige Signatur. Beide Aussagen, getestet:
// Determinism and malleability of ES256 vs EdDSA tokens, checked with jose and jsonwebtoken.
import { readFileSync } from "node:fs";
import { SignJWT, generateKeyPair, importJWK, jwtVerify } from "jose";
import jwt from "jsonwebtoken";
const claims = { sub: "user-42", exp: 1924992000 };
const es = await generateKeyPair("ES256");
const ed = await generateKeyPair("Ed25519");
const sig = (t) => t.split(".")[2].slice(0, 16) + "...";
const signTwice = async (alg, key) => [
await new SignJWT(claims).setProtectedHeader({ alg }).sign(key),
await new SignJWT(claims).setProtectedHeader({ alg }).sign(key),
];
const [es1, es2] = await signTwice("ES256", es.privateKey);
const [ed1, ed2] = await signTwice("EdDSA", ed.privateKey);
console.log(`ES256 same input twice: ${sig(es1)} vs ${sig(es2)} -> identical: ${es1 === es2}`);
console.log(`EdDSA same input twice: ${sig(ed1)} vs ${sig(ed2)} -> identical: ${ed1 === ed2}`);
// Second valid signature for the same ES256 token: (r, n - s).
const n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
const keys = JSON.parse(readFileSync("out/keys.json", "utf8"));
const flipS = (token) => {
const [h, p, s] = token.split(".");
const raw = Buffer.from(s, "base64url");
const sValue = BigInt("0x" + raw.subarray(32).toString("hex"));
const flipped = Buffer.from((n - sValue).toString(16).padStart(64, "0"), "hex");
return `${h}.${p}.${Buffer.concat([raw.subarray(0, 32), flipped]).toString("base64url")}`;
};
// For Ed25519, S + L encodes the same scalar mod L; RFC 8032 requires S < L.
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
const addL = (token) => {
const [h, p, s] = token.split(".");
const raw = Buffer.from(s, "base64url");
const S = BigInt("0x" + Buffer.from(raw.subarray(32)).reverse().toString("hex"));
const bumped = Buffer.from((S + L).toString(16).padStart(64, "0"), "hex").reverse();
return `${h}.${p}.${Buffer.concat([raw.subarray(0, 32), bumped]).toString("base64url")}`;
};
const check = async (label, token, alg, keyName) => {
const out = [];
try {
await jwtVerify(token, await importJWK(keys[keyName].jwk, alg), { algorithms: [alg] });
out.push("jose: accepted");
} catch (e) {
out.push(`jose: ${e.code}`);
}
if (alg === "ES256") {
try {
jwt.verify(token, keys[keyName].pem, { algorithms: [alg] });
out.push("jsonwebtoken: accepted");
} catch (e) {
out.push(`jsonwebtoken: ${e.message}`);
}
}
console.log(`${label.padEnd(30)} ${out.join(" | ")}`);
};
const esFlipped = flipS(tokens.ES256);
const edBumped = addL(tokens.EdDSA);
console.log(`ES256 token and its (r, n-s) twin differ: ${tokens.ES256 !== esFlipped}`);
await check("ES256 original", tokens.ES256, "ES256", "es");
await check("ES256 with s replaced by n-s", esFlipped, "ES256", "es");
await check("EdDSA original", tokens.EdDSA, "EdDSA", "ed");
await check("EdDSA with S replaced by S+L", edBumped, "EdDSA", "ed");
ES256 same input twice: b7-5MuAwyrsT26t_... vs ct-P2K38kCt5LlK4... -> identical: false
EdDSA same input twice: riZF8DpT02Grlv3U... vs riZF8DpT02Grlv3U... -> identical: true
ES256 token and its (r, n-s) twin differ: true
ES256 original jose: accepted | jsonwebtoken: accepted
ES256 with s replaced by n-s jose: accepted | jsonwebtoken: accepted
EdDSA original jose: accepted
EdDSA with S replaced by S+L jose: ERR_JWS_SIGNATURE_VERIFICATION_FAILED
Der ES256-Zwilling trägt dieselben Claims, mit ihm kann also niemand ein Token fälschen. Er bricht aber jede Logik, die den Token-String als Identität des Tokens behandelt. Wenn du ein geleaktes Token widerrufst, indem du seinen Hash speicherst, oder Anfragen anhand des Token-Strings deduplizierst, kann der Inhaber den Zwilling vorlegen und kommt durch. Identifiziere Tokens über einen Claim jti, nie über ihre Bytes. Die Normalisierung auf Low-S, die Bitcoin erzwingt, ist nicht Teil von JOSE, und keine der getesteten Bibliotheken wendet sie an.
Geschwindigkeit: kein entscheidender Faktor
Die verbreitete Behauptung, Ed25519 sei „doppelt so schnell“, hängt von der Implementierung ab. Auf derselben Maschine (arm64, ein Kern, in Docker), beim Signieren und Prüfen der Signatureingabe (Signing Input) eines JWT:
| Implementierung | ES256 signieren | ES256 prüfen | Ed25519 signieren | Ed25519 prüfen |
|---|---|---|---|---|
| Node.js 24.22, OpenSSL 3.5.8 | 64.840/s | 23.163/s | 60.249/s | 23.398/s |
| Standardbibliothek von Go 1.27.3 | 53.877/s | 25.778/s | 75.218/s | 35.639/s |
Mit OpenSSL liegen beide nur wenige Prozent auseinander; in Go ist Ed25519 etwa 40 % schneller als P-256. So oder so prüft ein einzelner Kern mehr als zwanzigtausend Tokens pro Sekunde, weit mehr, als das Netzwerk und das JSON-Parsing drumherum zulassen. Deine Hardware liefert andere absolute Zahlen. Die Messung in Node:
// Single-thread sign and verify throughput of a JWS signing input, ES256 vs Ed25519, node:crypto (OpenSSL).
import { generateKeyPairSync, sign, verify } from "node:crypto";
const input = Buffer.from("eyJhbGciOiJFUzI1NiJ9." + Buffer.from(JSON.stringify({ sub: "user-42", exp: 1924992000 })).toString("base64url"));
const algs = {
ES256: { keys: generateKeyPairSync("ec", { namedCurve: "P-256" }), hash: "sha256", opts: { dsaEncoding: "ieee-p1363" } },
Ed25519: { keys: generateKeyPairSync("ed25519"), hash: null, opts: {} },
};
const rate = (fn) => {
for (let i = 0; i < 500; i++) fn(); // warm-up
let count = 0;
const start = process.hrtime.bigint();
while (process.hrtime.bigint() - start < 2_000_000_000n) {
fn();
count++;
}
return Math.round(count / 2);
};
console.log(`node ${process.version}, OpenSSL ${process.versions.openssl}, ${process.arch}`);
for (const [name, { keys, hash, opts }] of Object.entries(algs)) {
const signature = sign(hash, input, { key: keys.privateKey, ...opts });
const s = rate(() => sign(hash, input, { key: keys.privateKey, ...opts }));
const v = rate(() => verify(hash, input, { key: keys.publicKey, ...opts }, signature));
console.log(`${name.padEnd(8)} sign ${String(s).padStart(6)}/s verify ${String(v).padStart(6)}/s`);
}
Unterstützung in Bibliotheken, getestet
Jede Bibliothek hat dieselben drei Tokens geprüft: ein ES256-Token, ein Ed25519-Token mit der Kennzeichnung EdDSA und das Token desselben Schlüssels mit der Kennzeichnung Ed25519. Jeder Aufruf erlaubte nur den Algorithmus des jeweiligen Tokens.
| Bibliothek | ES256 | EdDSA | Ed25519 |
|---|---|---|---|
| jose 6.2.13 (Node.js) | ja | ja | ja |
| jsonwebtoken 9.0.3 (Node.js) | ja | nein: Unknown key type "ed25519". | nein |
| PyJWT 2.15.1 | ja | ja | nein: Algorithm not supported |
| joserfc 1.7.7 (Python) | ja | ja, warnt mit EdDSA is deprecated via RFC 9864 | ja |
| golang-jwt v5.3.1 | ja | ja | nein: signing method (alg) is unavailable |
| go-jose v4.1.6 | ja | ja | nein: error in cryptographic primitive |
| jwx v3.3.0 (Go) | ja | ja | ja |
| Nimbus JOSE+JWT 10.10 (Java) | ja | ja, braucht die optionale Tink-Abhängigkeit | ja |
| JJWT 0.13.0 (Java, JDK 25) | ja | ja | nein: Unsupported JWS header 'alg' (Algorithm) value 'Ed25519'. |
| Microsoft.IdentityModel.JsonWebTokens 8.23.0 (.NET 10) | ja | nein: IDX10511: Signature validation failed. | nein |
| firebase/php-jwt 7.2.1 (PHP 8.5, sodium) | ja | ja | nein: Algorithm not supported |
| ruby-jwt 3.3.0 mit jwt-eddsa 1.0.0 | ja | ja; ohne die Erweiterung Key type OKP not supported | ja |
jsonwebtoken 11.1.0 (Rust, rust_crypto) | ja | ja | nein: InvalidAlgorithmName |
ES256 funktionierte in 13 von 13 Bibliotheken, EdDSA in 11, Ed25519 in 5. Die beiden Lücken, die die Frage am häufigsten entscheiden, liegen in den verbreitetsten Stacks: Das npm-Paket jsonwebtoken kennt überhaupt kein EdDSA, und Microsoft.IdentityModel, auf dem die JWT-Bearer-Authentifizierung von ASP.NET Core aufsetzt, meldet für einen Ed25519-JWK IsSupportedAlgorithm: False. Der .NET-Fehler ist die generische Meldung IDX10511, die dich nach einem falschen Schlüssel suchen lässt statt nach einem fehlenden Algorithmus. Nimbus ohne Tink im Classpath scheitert zur Laufzeit mit ClassNotFoundException: com.google.crypto.tink.subtle.Ed25519Verify.
In Browsern ist Ed25519 in der Web Cryptography API seit Firefox 129, Safari 17 und Chrome 137 verfügbar (Release Notes zu Chrome 137, Mai 2025), daher kann das Signieren im Browser, etwa von DPoP-Proofs, jetzt beide Kurven nutzen.
Unterstützung in Cloud-KMS und HSM
Liegt der Signaturschlüssel in einem verwalteten Schlüsseldienst, entscheidet der Dienst für dich. Laut der Dokumentation des jeweiligen Anbieters im Oktober 2026:
| Dienst | P-256 (ES256) | Ed25519 |
|---|---|---|
| AWS KMS | ECC_NIST_P256, ECDSA_SHA_256 | ECC_NIST_EDWARDS25519, ED25519_SHA_512, seit November 2025 |
| Google Cloud KMS | EC_SIGN_P256_SHA256 auf jeder Schutzstufe, einschließlich HSM | EC_SIGN_ED25519, nur auf der Schutzstufe Software |
| Azure Key Vault und Managed HSM | P-256 (EC und EC-HSM) | nicht angeboten |
| AWS CloudHSM | ja | hsm2m.medium, nur im Nicht-FIPS-Modus |
Quellen: Referenz der Schlüsselspezifikationen in AWS KMS, Sign-API von AWS KMS, Algorithmen in Google Cloud KMS, Schlüssel in Azure Key Vault, Schlüssel in Managed HSM, Releases des AWS CloudHSM Client SDK.
Zwei praktische Details:
- Kodierung. AWS KMS und Google Cloud KMS geben ECDSA-Signaturen DER-kodiert zurück. Ein JWS braucht die rohe 64-Byte-Form
r ‖ s(RFC 7518 §3.4), daher muss ein ES256-Signierer auf KMS-Basis umwandeln. Diese Umwandlung zu vergessen ist eine klassische Ursache für Fehler wegen ungültiger Signaturen, etwa „ES256 signatures must be 64 bytes“ von jsonwebtoken; ECDSA-Signaturformate erklärt die Umwandlung, und der Konverter DER ⇄ Raw erledigt sie für eine einzelne Signatur. - Nachrichtengröße. Ed25519 in AWS KMS verlangt
MessageType: RAW, undSignakzeptiert höchstens 4096 Byte. Die Signatureingabe eines JWS besteht aus dem kodierten Header und der kodierten Payload, daher lässt sich ein Token mit großen Claims in KMS nicht mit Ed25519 signieren. ECDSA hat keine solche Grenze, weil du mitMessageType: DIGESTeinen SHA-256-Digest senden kannst.
Was Standards und Anbieter verlangen
- OpenID Connect verlangt von Anbietern, RS256 für ID-Tokens zu unterstützen, mit einer engen Ausnahme (OIDC Core §15.1). Keine der beiden Kurven ersetzt das; Anbieter, die ES256 oder EdDSA anbieten, tun das zusätzlich.
- FAPI 2.0 Security Profile (finale Fassung, Februar 2025, §5.4.1) erlaubt für JWTs nur
PS256,ES256undEdDSAmit Ed25519 und hält fest, dass ein vollständig spezifizierter Bezeichner für Ed25519 erlaubt wäre, sobald er registriert ist – was RFC 9864 inzwischen getan hat. - Die Überarbeitung der JWT Best Practices (draft-ietf-oauth-rfc8725bis, in der Warteschlange des RFC Editor) weist neue Deployments an, die vollständig spezifizierten Namen aus RFC 9864 zu bevorzugen.
Keine der beiden Kurven bietet Post-Quanten-Sicherheit. ML-DSA hat inzwischen JOSE-Bezeichner (RFC 9964, Mai 2026), aber bei Tokens, die nur Minuten gelten, lautet die Frage, wann Verifizierer klassische Signaturen ablehnen müssen, und nicht, ob heutige Tokens später gelesen werden können. Erst wenn Algorithmen pro Schlüssel konfigurierbar bleiben, wie im nächsten Abschnitt, wird dieser Wechsel möglich.
Wählen und später wechseln
| Situation | Wahl |
|---|---|
Tokens, die Dritte, .NET-Dienste oder das npm-Paket jsonwebtoken prüfen | ES256 |
| Signaturschlüssel in Azure Key Vault, Managed HSM, Google Cloud HSM oder einem HSM im FIPS-Modus | ES256 |
| Alle Verifizierer sind in deiner Hand, der Schlüssel liegt in Software oder in AWS KMS | Ed25519 |
| Eine API oder ein Profil lässt nur bestimmte Algorithmen zu (FAPI 2.0, die Spezifikation eines Partners) | was dort aufgeführt ist; ES256 ist am häufigsten dabei |
Wenn du dich heute für Ed25519 entscheidest, gib "alg": "EdDSA" aus. Die Einstufung als veraltet in RFC 9864 ist ein SHOULD, das dokumentierte betriebliche Hindernisse zulässt, und ein Verifizierer, der den neuen Namen ablehnt, ist ein solches Hindernis. Akzeptiere beim Prüfen beide Namen, und zwar nur mit einem Schlüssel vom Typ OKP, dessen crv den Wert Ed25519 hat, damit ein veralteter Name nie eine Kurve auswählt, die du nicht vorgesehen hast. Mit jose:
const { payload } = await jwtVerify(token, ed25519PublicKey, {
algorithms: ["Ed25519", "EdDSA"],
issuer: "https://id.example.com",
audience: "https://api.example.com",
});
Gegen die Tokens dieses Artikels ausgeführt (jose 6.2.13), akzeptierte der Code sowohl das Token mit EdDSA als auch das mit Ed25519 und lehnte das ES256-Token mit "alg" (Algorithm) Header Parameter value not allowed ab. Ein späterer Wechsel des Algorithmus ist eine Schlüsselrotation: Veröffentliche den neuen Schlüssel mit eigenem kid und alg neben dem alten, signiere ab dann mit ihm und entferne den alten Schlüssel, nachdem das letzte alte Token abgelaufen ist. JWKS-Schlüsselrotation ohne Ausfallzeit erklärt das Timing Schritt für Schritt, und Fehler bei der JWT-Prüfung behandelt das Pinning des Algorithmus pro Schlüssel, damit nie das Token ihn auswählt.
Quellen
- RFC 7518 §3.4: ECDSA in JWS
- RFC 8032: EdDSA, §5.1.6 (Signieren) und §5.1.7 (Prüfung,
S < L) - RFC 8037: EdDSA und OKP-Schlüssel in JOSE
- RFC 9864: vollständig spezifizierte Algorithmen für JOSE und COSE
- IANA-Registry der JOSE-Algorithmen und Registry der COSE-Algorithmen
- RFC 6979: deterministisches ECDSA
- FIPS 186-5: Digital Signature Standard, einschließlich EdDSA
- FAPI 2.0 Security Profile, §5.4.1
- OpenID Connect Core 1.0, §15.1
- Dokumentation der Anbieter, verlinkt im Abschnitt zu KMS