ecdsa.com
← All articles

ES256 vs RS256 for JWTs: how to choose and how not to get burned

Two letters in a JWT header decide your token size, your key management story, and — if you configure verification carelessly — whether your tokens can be forged at all. Here is what ES256 and RS256 actually mean and how to use them safely.

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:

algSchemeKey typeSignature size
HS256HMAC with SHA-256One shared secret (symmetric)32 bytes
RS256RSASSA-PKCS1-v1_5 with SHA-256RSA key pair256 bytes (RSA-2048)
PS256RSASSA-PSS with SHA-256RSA key pair256 bytes (RSA-2048)
ES256ECDSA on P-256 with SHA-256EC key pair64 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 alg and kid; 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.
node — jose: algorithm allowlist pinned on the verify side
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",
});
python — PyJWT: same rule
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:

node — produce a JWS-ready 64-byte ES256 signature
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

Meeting 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 kid and alg values, and give consumers time to refresh their JWKS caches.
  • Start issuing ES256 tokens with the new kid. Verifiers that select keys by kid and 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.