ES256 Key Pairs Explained: Generating ECDSA Keys for JWT Signing
If you've compared HS256 vs RS256 and landed on asymmetric signing, ES256 is worth considering before you default to RS256. It's asymmetric like RS256 — a private key signs, a public key verifies, so verifiers never need forging capability — but it uses elliptic curve cryptography instead of RSA, which means smaller keys, smaller signatures, and faster operations for equivalent security. See RS256 vs ES256 for the full comparison; this guide covers the part that comparison doesn't: how to actually generate ES256 key pairs correctly, and the handful of ways people get it wrong the first time.
What ES256 Actually Specifies
"ES256" is not a general-purpose elliptic curve setting — it's a specific, fixed pairing defined by RFC 7518: ECDSA using the P-256 curve (also called prime256v1 or secp256r1 depending on the library) with SHA-256 as the hash function. You don't choose a curve the way you choose an RSA modulus length — the algorithm name fixes it. The related algorithms follow the same pattern: ES384 pairs with P-384, ES512 pairs with P-521. Generating a P-384 key and trying to sign with alg: ES256 will fail (or, worse in a poorly validated setup, silently produce something a sloppy verifier accepts) — the curve and the algorithm name must match.
Generating a P-256 Key Pair
Every production key should come from your platform's CSPRNG-backed elliptic curve implementation — never hand-rolled math.
Node.js:
const crypto = require('crypto');
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});Python (using `cryptography`):
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
private_key = ec.generate_private_key(ec.SECP256R1())
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)Go:
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
publicKey := &privateKey.PublicKeyOpenSSL (language-agnostic, good for one-off generation or CI):
openssl ecparam -name prime256v1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pemSigning and Verifying a JWT
With jsonwebtoken in Node.js:
const jwt = require('jsonwebtoken');
const token = jwt.sign({ sub: 'user-123' }, privateKey, { algorithm: 'ES256' });
const decoded = jwt.verify(token, publicKey, { algorithms: ['ES256'] });As with RS256, always pin the expected algorithm explicitly on verification (algorithms: ['ES256']). A verifier that trusts whatever alg the token claims is vulnerable to algorithm-confusion attacks regardless of which asymmetric algorithm you intended to use.
Two Things That Trip People Up the First Time
ECDSA signatures are non-deterministic by default. Sign the exact same payload with the exact same key twice, and you'll get two different — both valid — signatures. This is normal: standard ECDSA signing draws a fresh random nonce per signature. It's a real difference from HMAC (HS256), where the same input and key always produce the same output byte-for-byte. Don't write tests that assert a specific ES256 signature value; assert that verification succeeds instead. (RFC 6979 defines a deterministic-nonce variant some libraries support, but it's not what standard JWT libraries do by default, and you don't need it for correct JWT signing.)
JWS wants raw r‖s, not ASN.1 DER. This is the one that actually breaks things. OpenSSL's default ECDSA signature output is ASN.1 DER-encoded. The JWS spec (and every conforming JWT library) expects the raw, fixed-length concatenation of the r and s values instead — 32 bytes each for P-256, 64 bytes total, base64url-encoded. If you ever sign outside a JWT library (directly with OpenSSL or a low-level crypto API) and paste the result into a token by hand, you'll produce a signature verifiers reject. Stick to a proper JWT library (jsonwebtoken, PyJWT, golang-jwt, Nimbus JOSE+JWT) for the actual token construction — they handle the DER-to-raw conversion for you. Hand-rolling is where this bites.
Publishing the Public Key
As with RS256, distribute the public key via a JWKS document rather than shipping a raw PEM to every verifier — it supports multiple active keys via kid and clean rotation without downtime. See JWKS in Production for the operational details, and inspect or build JWKS documents with the JWKS Viewer.
Protecting the Private Key
Same discipline as any asymmetric signing key: never commit it, store it in a secrets manager or KMS in production, restrict access to the signing service only (verifiers need only the public key), and rotate on a schedule — immediately if compromise is suspected. See RSA key pairs for JWT signing for the full storage hierarchy; it applies identically here.
Frequently Asked Questions
Can I use a different curve with ES256?
No. ES256 specifically means ECDSA with P-256 and SHA-256 — the algorithm name fixes the curve. If you want a different curve, you need a different algorithm name (ES384 for P-384, ES512 for P-521), and your library and verifiers all need to agree on it.
Is ES256 more secure than RS256?
Not in a simple "more secure" sense — the two rest on different mathematical hard problems (elliptic curve discrete log vs. integer factorization), and both are considered secure at their standard recommended sizes (P-256 for ES256, 2048-bit for RS256). ES256's real advantage is practical: smaller keys, smaller signatures, and faster signing/verification, not a larger security margin.
Why did I get a different signature the second time I signed the same payload?
That's expected ECDSA behavior, not a bug — standard ECDSA signing is non-deterministic. Verify correctness by checking that jwt.verify() succeeds, not by comparing signature bytes across runs.
Can I convert my existing RSA keys to ES256?
No — RSA and EC keys are mathematically unrelated; there's no conversion. You need to generate a fresh P-256 key pair, update your signing service to use it, publish the new public key (or new JWKS entry with a new kid), and run both algorithms in parallel during rollover until old RS256 tokens expire.
Should I pick ES256 or RS256 for a new project?
If you're choosing fresh and don't have a specific compliance or library-support constraint, ES256's smaller footprint is attractive — but check that every verifier in your stack (including any third-party SDKs) actually supports ES256 first, since RS256 has somewhat broader legacy support. See RS256 vs ES256 for a full side-by-side.
---
You can generate an ES256 key pair with the snippets above, or with OpenSSL for a quick one-off. Prefer RS256 instead? Our free RSA Key Generator runs entirely in your browser.
*Related: RS256 vs ES256 · HS256 vs ES256 · RSA key pairs for JWT signing · JWKS in Production*