What the alg names actually mean
A JWT is a JWS (JSON Web Signature) with a JSON payload: header.payload.signature, each part base64url-encoded. The alg header names the signature scheme, and the registry lives in RFC 7518 (JSON Web Algorithms). The four values you meet in practice:
| alg | Scheme | Key type | Signature size |
|---|---|---|---|
HS256 | HMAC with SHA-256 | One shared secret (symmetric) | 32 bytes |
RS256 | RSASSA-PKCS1-v1_5 with SHA-256 | RSA key pair | 256 bytes (RSA-2048) |
PS256 | RSASSA-PSS with SHA-256 | RSA key pair | 256 bytes (RSA-2048) |
ES256 | ECDSA on P-256 with SHA-256 | EC key pair | 64 bytes (raw r‖s) |
Two structural points hide in this table. First, HS256 is fundamentally different: it is symmetric, so every party that can verify a token can also mint one. It only fits when issuer and verifier are the same service. RS256, PS256 and ES256 are asymmetric: verifiers hold only the public key and can be handed it freely, e.g. via a JWKS endpoint.
Second, the ES names pin the curve: ES256 means P-256 with SHA-256, ES384 means P-384 with SHA-384, and ES512 means — mind the numbers — the 521-bit curve P-521 with SHA-512. PS256 is the same RSA key material as RS256 with the newer PSS padding; if your ecosystem supports it, PSS is the better RSA mode. (For the deeper algorithm comparison beyond JWTs, see ECDSA vs RSA.)
Token size: 64 bytes vs 256 bytes of signature
An ES256 signature is 64 raw bytes — about 86 base64url characters. An RS256 signature with the common 2048-bit key is 256 bytes — about 342 characters — and 384 bytes with RSA-3072. Since header and payload are the same either way, switching RS256 → ES256 typically shaves ~250 bytes off every token. That matters when tokens ride in cookies (4 KB budget), in Authorization headers on every request, in QR codes, or in mobile/IoT traffic. The ES256 public key is also far smaller, which keeps JWKS documents compact.
Speed follows the pattern described in the ECDSA vs RSA guide: ES256 signs faster; RS256 verifies faster. For a token issuer signing at high volume, ES256 is the cheaper side of the trade.
The rule that prevents most JWT disasters: pin the algorithm
The classic JWT configuration mistake is letting the token decide how it will be verified. The alg header is attacker-controlled input — it arrives inside the very token you have not verified yet. A verifier that reads alg from the header and dispatches on it, with one key object serving multiple algorithm families, breaks its own guarantees. The well-known confusion case: a service verifies RS256 tokens with a public key, but its library also accepts HS256 with the same key object — treating the RSA public key bytes as an HMAC secret. Since the public key is public by design, anyone can compute a matching HS256 tag and mint tokens the service will accept.
The fix is mechanical, and it is codified in RFC 8725 (JWT Best Current Practices):
- Verify with an explicit algorithm allowlist — ideally a single value — configured on the server side, never derived from the token.
- Bind keys to algorithms. In a JWKS, give each key its
algandkid; a key published for ES256 must never be usable for anything else. Key type checks help too: an EC P-256 key physically cannot verify RS256, which is one more argument for ES256 in mixed environments. - Validate claims explicitly — issuer, audience, expiry — in the same call.
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",
)Why "none" is never acceptable
RFC 7518 also registers "alg": "none" — the "unsecured JWS", a token with an empty signature part. It exists for the rare case where integrity is guaranteed by some outer layer. A verifier that accepts none accepts unsigned tokens: anyone can craft any payload and present it. Early JWT libraries (2015-era) famously treated none as just another algorithm, which made unsigned tokens pass verification by default. Modern libraries reject it unless explicitly enabled — and with an algorithm allowlist of ["ES256"], it can never be selected. If you are auditing a codebase, any code path that can reach none or an empty allowlist is a finding.
The ES256 trap: JWS signatures are raw r‖s, not DER
An ECDSA signature is mathematically a pair of integers (r, s), and there are two common byte encodings for that pair. RFC 7518 §3.4 is explicit: in a JWS, the ES256 signature is r and s as two 32-byte big-endian integers, concatenated — 64 bytes exactly, then base64url-encoded. It is not the ASN.1 DER SEQUENCE that OpenSSL, Java's Signature, and Node's crypto.sign() produce by default.
This is one of the most common causes of "invalid signature" in hand-rolled JWT code: the token carries a ~70-byte DER blob (or the verifier feeds a 64-byte raw signature to a DER-expecting API), and verification fails even though the key, message and algorithm are all correct. If you sign with Node's built-in crypto, ask for the JWS format directly:
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); // 64Meeting a signature of the wrong shape in the wild? The DER ⇄ raw converter converts in both directions and shows the r and s values; the byte-level details, along with the high-S subtlety, are covered in ECDSA signature formats: DER vs raw. And to check a real token — header, claims, and an ES256 verification against your public key — paste it into the JWT debugger.
Choosing — and migrating without breakage
Migrating an existing RS256 deployment to ES256 is a key-rotation exercise, not a flag day:
- Publish the new EC key in your JWKS alongside the RSA key, each with correct
kidandalgvalues, and give consumers time to refresh their JWKS caches. - Start issuing ES256 tokens with the new
kid. Verifiers that select keys bykidand enforce a per-key algorithm keep accepting both token generations, each against exactly one key. - Once every RS256 token has expired, remove the RSA key from the JWKS and shrink the verifier allowlist back to
["ES256"].
The single sentence to remember: the verifier, not the token, chooses the algorithm. Get that right and the ES256 vs RS256 decision becomes what it should be — a mild engineering trade-off about bytes and milliseconds.