JWT Algorithms: HS256, RS256, ES256, EdDSA
JWT signing algorithms: HS256, RS256, PS256, ES256, EdDSA. Key sizes, signature sizes, code examples, JWKS key rotation, and a decision matrix.
The algorithm you choose for signing JWTs is not easily changed after deployment. The alg value is embedded in every token header. Every service in your system that verifies tokens must support it. Third-party identity providers and downstream APIs that accept your tokens must understand it. Getting it wrong and fixing it later means rotating signing keys, reissuing every active token, updating every verifier, and coordinating the rollout without dropping user sessions.
This guide covers every JWT signing algorithm defined in RFC 7518, plus EdDSA from RFC 8037: how each one works, the exact numbers (key sizes, signature sizes, performance), when to use it, and complete code for key generation and token signing in Node.js and Python.
Each major algorithm also has its own dedicated deep-dive page with a comparison table and FAQs:
-
If you only need a quick answer: use ES256 for new distributed systems, RS256 when integrating with Auth0/Okta/AWS Cognito, PS256 if you use RS256 and want a free cryptographic upgrade, and HS256 for single-service applications with no external verifiers.
How the alg field works
Every JWT header contains an alg field. Verifiers read it to determine how to check the signature. This is also the root of the algorithm confusion vulnerability: a correctly implemented verifier ignores this field and uses a hardcoded expected algorithm. But understanding what valid alg values exist and what they mean is foundational.
RFC 7518 algorithm names follow a consistent pattern. The prefix identifies the algorithm family:
HS- HMAC (Hash-based Message Authentication Code), symmetricRS- RSASSA-PKCS1-v1_5, asymmetric RSA with the original PKCS#1 v1.5 paddingPS- RSASSA-PSS, asymmetric RSA with Probabilistic Signature Scheme paddingES- ECDSA (Elliptic Curve Digital Signature Algorithm), asymmetric
The number identifies the SHA hash variant: 256 means SHA-256, 384 means SHA-384, 512 means SHA-512. So PS384 is RSA-PSS with SHA-384. ES512 is ECDSA with SHA-512 - using the P-521 curve (more on that naming anomaly later).
EdDSA, defined in RFC 8037, breaks the pattern. It is simply its own name. The specific curve (Ed25519 or Ed448) is stored in the JWK key definition, not in the alg string.
The full list of valid alg values for JWT signing: HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, ES256K, EdDSA, and none.
none means unsigned. It should never appear in any production token and should never be accepted by any verifier. ES256K uses the secp256k1 curve from Bitcoin and Ethereum and is discussed briefly at the end of the ECDSA section.
The HMAC family: HS256, HS384, HS512
HMAC algorithms are symmetric. The same secret key signs the token and verifies it. The security consequence: any service that can verify an HS256 token can also forge one. If five microservices all verify tokens with the same secret, any one of them that is compromised becomes a token minter for the entire system.
The signature is computed as:
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
The output is 32 bytes for HS256, 48 bytes for HS384, and 64 bytes for HS512. HMAC signatures are the smallest of any JWT algorithm, which is why HS256 tokens are noticeably more compact than RS256 tokens.
When to use HMAC: HS256 is correct when the token issuer and all verifiers live in the same trust boundary - typically a single application or a small cluster of services that already share secrets through a secure channel like a secrets manager. The moment tokens need to be verified by an external party, a third-party API, or a service you do not fully control, switch to an asymmetric algorithm.
Generating a secure HMAC secret:
# Linux / macOS / Git Bash
openssl rand -base64 32
# Example output: K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=
// Node.js
const crypto = require("crypto");
const secret = crypto.randomBytes(32).toString("base64url");
# Python
import secrets
secret = secrets.token_urlsafe(32)
The minimum is 32 bytes for HS256, 48 bytes for HS384, 64 bytes for HS512. Not a password, UUID, or memorable phrase.
Signing and verifying:
// Node.js - jsonwebtoken
const jwt = require("jsonwebtoken");
const secret = process.env.JWT_SECRET; // 32+ bytes, loaded from secrets manager
const token = jwt.sign(
{ sub: "user_123", role: "user" },
secret,
{
algorithm: "HS256",
expiresIn: "15m",
issuer: "https://auth.example.com",
audience: "https://api.example.com",
}
);
const payload = jwt.verify(token, secret, {
algorithms: ["HS256"], // hardcoded - never read from token header
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});
# Python - PyJWT
import jwt, os
secret = os.environ["JWT_SECRET"]
token = jwt.encode(
{"sub": "user_123", "role": "user"},
secret,
algorithm="HS256",
)
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
issuer="https://auth.example.com",
audience="https://api.example.com",
)
The RSA family: RS256, RS384, RS512
RSA is the most widely deployed asymmetric JWT algorithm. Auth0, Okta, AWS Cognito, and Google all default to RS256. If you are validating tokens from any major identity provider, you are almost certainly using RS256 whether you consciously chose it or not.
RSA signing uses a private key held only by the authorization server. The corresponding public key can be published openly and distributed to every service that needs to verify tokens. A service that verifies tokens cannot forge them.
The signature size equals the RSA key size: a 2048-bit RSA key produces a 256-byte signature. A 4096-bit key produces a 512-byte signature. This makes RS256 tokens significantly larger than HMAC or ECDSA tokens.
Key size guidance:
-
2048 bits: minimum acceptable. ~112-bit security. Supported universally.
-
3072 bits: ~128-bit security - equivalent to ES256 with P-256. Use this if you want symmetric security parity.
-
4096 bits: ~140-bit security. Larger tokens, slower signing. Rarely necessary.
For most production systems, 2048-bit keys are sufficient and universal.
Why RS256 is the default for identity providers:
RS256 integrates naturally with JWKS (JSON Web Key Set). An authorization server publishes its public keys at /.well-known/jwks.json. Each key carries a kid identifier that matches the kid field in every JWT header the server issues. Verifiers fetch the JWKS once, cache it with a TTL, find the key matching the token’s kid, and verify. Key rotation is transparent: the auth server publishes old and new public keys simultaneously, verifiers automatically pick up the new key, and old tokens continue to verify until they expire.
Generating RSA keys:
# 2048-bit RSA key pair
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa-private.pem
openssl pkey -pubout -in rsa-private.pem -out rsa-public.pem
# 3072-bit for 128-bit security equivalence
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out rsa3072-private.pem
openssl pkey -pubout -in rsa3072-private.pem -out rsa3072-public.pem
// Node.js
const { generateKeyPairSync } = require("crypto");
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
Signing and verifying:
// Node.js
const token = jwt.sign(
{ sub: "user_123" },
privateKey,
{
algorithm: "RS256",
expiresIn: "15m",
issuer: "https://auth.example.com",
audience: "https://api.example.com",
keyid: "rsa-key-v1", // matches kid in JWKS
}
);
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});
# Python - PyJWT
token = jwt.encode(
{"sub": "user_123"},
private_key,
algorithm="RS256",
headers={"kid": "rsa-key-v1"},
)
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="https://api.example.com",
)
The RSA-PSS family: PS256, PS384, PS512
RSA-PSS uses the same RSA key pairs as the RS family but with an improved padding scheme. PS256 is a drop-in cryptographic upgrade from RS256: the same private and public keys, the same JWKS, just a different algorithm identifier in your JWT library configuration.
Why PS256 is cryptographically better than RS256:
RS256 uses PKCS#1 v1.5 padding, designed in 1993. It is correct in practice but lacks a formal security proof. PS256 uses PSS padding, which has a proven security reduction to the RSA problem: breaking PS256 is mathematically equivalent to breaking RSA itself. PKCS#1 v1.5 is also theoretically susceptible to Bleichenbacher-type padding oracle attacks. PSS is not.
RFC 7518 explicitly recommends PS256 over RS256 for new deployments. The Financial-grade API security profile (FAPI 1.0 Advanced) mandates PS256 or ES256 and explicitly discourages RS256. Open Banking in the UK and several regulated financial API standards have required PS256.
Migrating from RS256 to PS256 is a one-line change:
// Before
const token = jwt.sign(payload, privateKey, { algorithm: "RS256" });
const decoded = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
// After - same RSA keys, only the algorithm string changes
const token = jwt.sign(payload, privateKey, { algorithm: "PS256" });
const decoded = jwt.verify(token, publicKey, { algorithms: ["PS256"] });
The signature is still 256 bytes for a 2048-bit RSA key. Token size is identical. The only change is the padding applied inside the RSA operation.
Library support: All major modern JWT libraries support PS256 - jsonwebtoken ≥ 9.x, PyJWT ≥ 2.x, jjwt for Java, jose for JavaScript. Older libraries and some embedded or FIPS-restricted environments may not. Verify support across your full stack before switching.
The ECDSA family: ES256, ES384, ES512
ECDSA performs asymmetric signing with elliptic curve cryptography. The security relies on the difficulty of the elliptic curve discrete logarithm problem rather than integer factorization, which allows much shorter keys at equivalent security levels.
The three ECDSA variants:
-
ES256: P-256 curve (secp256r1 / prime256v1) with SHA-256. ~128-bit security. 256-bit key. 64-byte signature. -
ES384: P-384 curve with SHA-384. ~192-bit security. 384-bit key. 96-byte signature. -
ES512: P-521 curve with SHA-512. ~256-bit security. 521-bit key. 132-byte signature.
Why ECDSA tokens are smaller than RSA tokens:
An RS256 signature is 256 bytes for a 2048-bit RSA key. An ES256 signature is 64 bytes. Both provide roughly 128-bit security. The ES256 signature is four times smaller, which translates directly into smaller tokens. For applications that carry tokens in cookies or HTTP headers on every request, this matters for both bandwidth and header size limits.
The ECDSA nonce vulnerability:
Every ECDSA signature requires a random value (the k-value or nonce) generated during signing. If this nonce is ever reused across two different messages, or is weakly generated, an attacker can mathematically recover the private key from those two signatures. This is not theoretical: it is precisely how the PS3 private key was extracted in 2010. Sony used a constant k-value for every ECDSA signature on the PlayStation 3. Two signatures, same k, private key fully recovered.
Modern JWT libraries generate nonces correctly using CSPRNGs. But it is worth understanding the risk - and it is the primary reason EdDSA was designed.
ES256K uses the secp256k1 curve, the same curve used by Bitcoin and Ethereum. It appears in JWT implementations for blockchain identity, FIDO2-adjacent applications, and some verifiable credential systems. For standard API authentication, ES256 with P-256 is the correct choice.
Generating EC keys:
# ES256 - P-256
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ec256-private.pem
openssl pkey -pubout -in ec256-private.pem -out ec256-public.pem
# ES384 - P-384
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out ec384-private.pem
openssl pkey -pubout -in ec384-private.pem -out ec384-public.pem
# ES512 - P-521 (note: P-521, not P-512)
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-521 -out ec521-private.pem
openssl pkey -pubout -in ec521-private.pem -out ec521-public.pem
// Node.js - ES256
const { generateKeyPairSync } = require("crypto");
const { privateKey, publicKey } = generateKeyPairSync("ec", {
namedCurve: "P-256",
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
Signing and verifying:
// Node.js
const token = jwt.sign(
{ sub: "user_123" },
ecPrivateKey,
{
algorithm: "ES256",
expiresIn: "15m",
keyid: "ec-key-v1",
}
);
const payload = jwt.verify(token, ecPublicKey, {
algorithms: ["ES256"],
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});
# Python
token = jwt.encode({"sub": "user_123"}, ec_private_key, algorithm="ES256")
payload = jwt.decode(token, ec_public_key, algorithms=["ES256"])
EdDSA: Ed25519 and Ed448
EdDSA (Edwards-curve Digital Signature Algorithm) is defined in RFC 8037. It is the most modern algorithm available for JWT signing and addresses a fundamental weakness in ECDSA.
Ed25519 characteristics:
-
256-bit key
-
64-byte signature (same size as ES256)
-
~128-bit security
-
Deterministic: no random nonce during signing
-
Fastest signing of any JWT algorithm
Ed448 characteristics:
-
448-bit key
-
114-byte signature
-
~224-bit security
-
Also deterministic
Why deterministic signing matters:
ECDSA requires a fresh random k-value for every signature. EdDSA does not. The signature for a given private key and message is always the same value, derived from a hash of the private key and the message itself. There is no nonce to reuse, predict, or leak. The entire class of nonce-reuse attacks - including the PS3 incident - is simply not possible with EdDSA.
Performance: Ed25519 is one of the fastest signature algorithms in existence. It consistently outperforms both RSA and ECDSA in both signing and verification speed on modern hardware.
Library support: jose (JavaScript), PyJWT ≥ 2.x, jjwt (Java, recent versions), golang’s go-jose, and most modern JWT libraries support EdDSA. Older deployments, FIPS-restricted environments, and some cloud provider token validation toolchains may not. Always verify EdDSA support across every library in your verification path before adopting it.
EdDSA library-support matrix by language:
| Language | Library | EdDSA / Ed25519 support | Notes |
|---|---|---|---|
| JavaScript / TypeScript | jose | ✅ Yes (recommended) | First-class EdDSA support; use importPKCS8/importSPKI with "EdDSA" |
| JavaScript / TypeScript | jsonwebtoken | ❌ No | Does not support EdDSA — use jose instead |
| Python | PyJWT ≥ 2.x | ✅ Yes | Requires cryptography backend; algorithm="EdDSA" |
| Java / Kotlin | jjwt | ✅ Yes (recent versions) | Requires BouncyCastle provider on most JDKs |
| Go | go-jose (Square) | ✅ Yes | ed25519 key type supported |
| Rust | jsonwebtoken | ⚠️ Limited | EdDSA support is incomplete in some versions — check the release notes for your pinned version |
| C# / .NET | System.IdentityModel.Tokens.Jwt | ⚠️ .NET 8+ | Native Ed25519 in .NET 8+; older runtimes need external crypto |
If your language’s primary JWT library does not support EdDSA and you cannot switch libraries, use ES256 instead — it gives you the same 64-byte signatures and ~128-bit security with broader library coverage. The only thing you lose is EdDSA’s deterministic signing (the ECDSA nonce risk, which modern libraries handle correctly via CSPRNG).
Generating Ed25519 keys:
# Requires OpenSSL 1.1.1+
openssl genpkey -algorithm ed25519 -out ed25519-private.pem
openssl pkey -pubout -in ed25519-private.pem -out ed25519-public.pem
// Node.js
const { generateKeyPairSync } = require("crypto");
const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
Signing and verifying (use jose, not jsonwebtoken):
// Node.js - jose library
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
const privateKey = await importPKCS8(edPrivateKeyPem, "EdDSA");
const publicKey = await importSPKI(edPublicKeyPem, "EdDSA");
const token = await new SignJWT({ sub: "user_123" })
.setProtectedHeader({ alg: "EdDSA", kid: "ed25519-key-v1" })
.setExpirationTime("15m")
.setIssuer("https://auth.example.com")
.setAudience("https://api.example.com")
.sign(privateKey);
const { payload } = await jwtVerify(token, publicKey, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});
# Python - PyJWT 2.x
import jwt
token = jwt.encode({"sub": "user_123"}, ed_private_key, algorithm="EdDSA")
payload = jwt.decode(token, ed_public_key, algorithms=["EdDSA"])
Algorithm comparison
| Algorithm | Symmetric | Recommended min key size | Sig size¹ | Security | Compatibility |
|---|---|---|---|---|---|
| HS256 | Yes | 256-bit secret | 32 bytes | Key-dependent | Universal |
| HS384 | Yes | 384-bit secret | 48 bytes | Key-dependent | Universal |
| HS512 | Yes | 512-bit secret | 64 bytes | Key-dependent | Universal |
| RS256 | No | 2048-bit RSA | key size ÷ 8 | ~112-bit | Universal |
| RS384 | No | 2048-bit RSA | key size ÷ 8 | ~112-bit | Universal |
| RS512 | No | 2048-bit RSA | key size ÷ 8 | ~112-bit | Universal |
| PS256 | No | 2048-bit RSA | key size ÷ 8 | ~112-bit | Modern |
| PS384 | No | 2048-bit RSA | key size ÷ 8 | ~112-bit | Modern |
| PS512 | No | 2048-bit RSA | key size ÷ 8 | ~112-bit | Modern |
| ES256 | No | 256-bit EC (P-256) | 64 bytes | ~128-bit | Modern |
| ES384 | No | 384-bit EC (P-384) | 96 bytes | ~192-bit | Modern |
| ES512 | No | 521-bit EC (P-521) | 132 bytes | ~256-bit | Modern |
| EdDSA (Ed25519) | No | 256-bit | 64 bytes | ~128-bit | Growing |
¹ The RSA signature size equals the key size in bytes (a 2048-bit key → 256-byte signature; a 3072-bit key → 384-byte signature; a 4096-bit key → 512-byte signature). The SHA variant (256/384/512) does not dictate the RSA key size — that is an independent choice. 2048 bits is the recommended minimum for all RS/PS algorithms; use 3072 bits only if you want ~128-bit security equivalence with ES256. The table lists the minimum; pick a larger key only if your threat model justifies it.
“Modern” means supported by all current JWT libraries across major languages. “Growing” means supported by most but not all, and active ecosystem adoption is in progress.
How to choose an algorithm
The decision comes down to three questions: who verifies the tokens, what library ecosystem do you operate in, and how much does token size matter?
Single service, no external verifiers: HS256 with a 32-byte random secret. No key infrastructure, no JWKS, no asymmetric math. Simplest correct choice for monolithic or tightly controlled deployments.
Multiple internal services, one central auth server: RS256 if your identity platform defaults to it (Auth0, Okta, AWS Cognito, Keycloak, Entra ID all do). PS256 if you control the issuer and want the RFC 7518-recommended padding with no key changes. ES256 if you want smaller tokens and control the full stack.
Tokens consumed by third parties or external APIs: RS256 for maximum compatibility. Every JWT library in every language supports RS256. Third-party systems almost certainly support it.
Financial-grade or regulated applications: PS256 or ES256. The FAPI 1.0 Advanced profile mandates one of these two and explicitly discourages RS256. Open Banking and several financial API standards require PS256.
Greenfield system, modern stack, no legacy constraints: ES256 or EdDSA. ES256 has broader library coverage and JWKS support now. EdDSA eliminates the ECDSA nonce risk and is faster - use it if your full stack supports it.
Upgrading from RS256 without rotating keys: PS256. One-line change. Immediate improvement.
JWKS: publishing and rotating asymmetric keys
JWKS (JSON Web Key Set, RFC 7517) is the standard for publishing asymmetric public keys so verifiers can retrieve and cache them. Any system using RS256, PS256, or ES256 should publish a JWKS endpoint.
What a JWKS response looks like:
{
"keys": [
{
"kty": "EC",
"use": "sig",
"kid": "ec-key-2026-06",
"alg": "ES256",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
]
}
For RSA:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "rsa-key-2026-06",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWh...",
"e": "AQAB"
}
]
}
The kid in the JWKS entry matches the kid in every JWT header the server issues with that key. Verifiers fetch the JWKS, find the matching key, and verify the signature.
Serving the JWKS endpoint:
// Express.js
const jwksKeyStore = loadPublicKeysFromSecretsManager();
app.get("/.well-known/jwks.json", (req, res) => {
res.set("Cache-Control", "public, max-age=3600");
res.json({ keys: Object.values(jwksKeyStore) });
});
Cache the JWKS response on the client side with a TTL of 10 to 60 minutes. Never fetch the JWKS on every token verification - this adds latency and makes your auth server a synchronous dependency for every authenticated request.
Zero-downtime key rotation in four phases:
Key rotation is exactly what JWKS was designed for. The process has four distinct phases:
-
Generate the new key pair. Add the new public key to the JWKS response alongside the existing one. Both keys are published. The new key has a distinct
kid. No tokens use the new key yet. -
Start signing new tokens with the new key. Set the new
kidin JWT headers for all newly issued tokens. Downstream verifiers find the new key in the JWKS and verify correctly. Existing tokens signed with the old key continue to verify because the old public key is still in the JWKS. -
Wait for the old tokens to expire naturally. The transition window equals your maximum token lifetime. After it passes, no valid unexpired token carries the old
kid. -
Remove the old public key from the JWKS. The old key is retired. Only the new key remains in service.
This process requires no coordination with downstream services and produces zero authentication failures. It is how Auth0, Okta, and every major identity provider rotates keys on a schedule without any observable impact on users.
Algorithm and JWKS checklist
Algorithm configuration:
-
Your verification call hardcodes the accepted algorithm and never reads it from the token header
-
alg: noneis not accepted in any capitalization -
You do not accept both RS256 and HS256 in the same algorithms array unless you have a documented architectural reason
-
ES512 keys use the P-521 curve, not P-512
Key and secret hygiene:
-
HS256/384/512 secrets are at least 32/48/64 bytes of data from a CSPRNG, never passwords or UUIDs
-
Asymmetric private keys are stored in a secrets manager or HSM, never in environment variables or source code
-
RSA keys are at least 2048 bits; 3072 bits for 128-bit security equivalence
-
EC keys use the correct named curve for the chosen algorithm variant
JWKS deployment:
-
JWKS endpoint is served over HTTPS only
-
kidvalues in JWT headers match keys in the JWKS -
Verifiers cache the JWKS with a TTL; they do not fetch on every request
-
Key rotation follows the 4-phase process: publish new key → switch signing → wait for expiry → remove old key
-
Only public keys appear in the JWKS; private keys are never published
Continue reading
- JWT decoder → - paste any token to decode, verify, and inspect it in your browser
- JWT Security Vulnerabilities → - alg:none, algorithm confusion, kid injection, and every fix
- Registered Claims Reference → - iss, sub, aud, exp, jti, and every RFC 7519 claim
- Introduction to JWTs → - the three-part structure and when to use JWTs
- JWT Security Vulnerabilities hub → - every vulnerability class as an indexable reference page