What Makes ES256 the Modern JWT Default
ES256 combines two well-vetted primitives: the NIST P-256 elliptic curve (also called secp256r1 or prime256v1) for the digital signature scheme, and SHA-256 for hashing the signing input. Together they provide 128 bits of security — the same as AES-128 — with keys and signatures small enough to fit anywhere.
Compared to RS256 at 2048-bit RSA (which provides ~112 bits of security), ES256 is faster, smaller, and slightly stronger. Compared to Ed25519 (a newer alternative gaining adoption), ES256 has broader hardware acceleration and stricter regulatory approval. If you are choosing an asymmetric algorithm for a new JWT deployment and you do not have a specific reason to use RSA, use ES256.
Real-World ES256 Deployments
- Apple Sign in with Apple — id_tokens returned by Apple are signed with ES256. You verify them using the public keys published at
appleid.apple.com/auth/keys. - Apple Push Notification service (APNs) — provider authentication uses ES256 JWTs (called "provider authentication tokens") signed with a key downloaded from your Apple Developer account.
- App Store Server API — subscription and transaction endpoints require ES256 JWTs signed with your Apple Developer team key.
- WebAuthn / FIDO2 — many authenticators produce ES256 attestation signatures that are wrapped as JWTs when transmitted server-to-server.
- DPoP (Demonstrating Proof of Possession, RFC 9449) — the modern OAuth 2.0 sender-constrained access token proof uses ES256 by default.
Signing and Verifying ES256 in Every Major Language
Node.js — jose (recommended)
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
// Sign
const privateKey = await importPKCS8(pkcs8Pem, 'ES256');
const jwt = await new SignJWT({ sub: '12345' })
.setProtectedHeader({ alg: 'ES256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey);
// Verify
const publicKey = await importSPKI(spkiPem, 'ES256');
const { payload } = await jwtVerify(jwt, publicKey, {
algorithms: ['ES256'],
});Python — PyJWT + cryptography
import jwt
from cryptography.hazmat.primitives import serialization
# Load keys
with open("private.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
with open("public.pem", "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
# Sign
token = jwt.encode({"sub": "12345"}, private_key, algorithm="ES256")
# Verify
payload = jwt.decode(
token,
public_key,
algorithms=["ES256"],
)Go — golang-jwt/jwt
import (
"crypto/ecdsa"
"github.com/golang-jwt/jwt/v5"
)
// Sign
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"sub": "12345",
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenString, err := token.SignedString(ecPrivateKey) // *ecdsa.PrivateKey
// Verify
parsed, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, fmt.Errorf("unexpected alg")
}
return ecPublicKey, nil // *ecdsa.PublicKey
})Java — jjwt
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.spec.ECGenParameterSpec;
// Generate ES256 keypair
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair kp = kpg.generateKeyPair();
// Sign
String jws = Jwts.builder()
.setSubject("12345")
.signWith(kp.getPrivate(), SignatureAlgorithm.ES256)
.compact();
// Verify
Claims claims = Jwts.parserBuilder()
.setSigningKey(kp.getPublic())
.build()
.parseClaimsJws(jws)
.getBody();ES256 Signature Format — Raw vs DER
A subtle but critical detail: JWT ES256 signatures MUST be the raw concatenation of the two ECDSA scalars r and s, each padded to 32 bytes big-endian, total 64 bytes. Many crypto libraries default to producing DER-encoded ECDSA signatures — a variable-length ASN.1 structure that is 70–72 bytes.
If you sign a JWT using a low-level crypto API that produces DER, and then base64url-encode it directly, the resulting JWT will fail verification on every compliant verifier. Use a JWT library rather than raw crypto — jose,PyJWT, jsonwebtoken in Ruby, golang-jwt, etc. all handle the DER↔raw conversion for you.
Common ES256 Pitfalls
- DER-encoded signature. Result: verification fails everywhere. Use a proper JWT library, not raw
crypto.sign("ecdsa"). - Using the wrong curve. ES256 is P-256 only. Signing with P-384 (that's ES384) or secp256k1 (that's ES256K, a non-standard extension used by blockchains) will not interoperate.
- Confusing ES256 with ES256K. ES256 uses NIST P-256; ES256K uses secp256k1 (the Bitcoin curve). They are NOT interchangeable.
- Signing without a fresh nonce. Never reuse ECDSA nonces — see the ECDSA nonce reuse FAQ. Every library handles this correctly; do not roll your own.
- Losing the private key. Unlike a shared HMAC secret, an ECDSA private key cannot be re-derived. Back it up encrypted in a secrets manager; if you lose it you must rotate to a new key and reissue all tokens.
- Confusing SPKI and PKCS8 formats. SPKI is the public key format (-----BEGIN PUBLIC KEY-----). PKCS8 is the private key format (-----BEGIN PRIVATE KEY-----). Some tools also produce SEC1 format (-----BEGIN EC PRIVATE KEY-----); convert with
openssl pkcs8 -topk8 -nocrypt -in sec1.pem -out pkcs8.pem.
Generating an ES256 Keypair
# OpenSSL — most portable
openssl ecparam -genkey -name prime256v1 -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem
# Node.js
const { generateKeyPairSync } = require('crypto');
const { publicKey, privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
# Python
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(),
)Related JWT Tools
- JWT RS256 Decoder — RSA-signed JWTs
- JWT HS256 Decoder — symmetric HMAC variant
- Verify JWT Signature — universal signature verifier
- JWT Decoder Online — decode any JWT algorithm
- Hash Generator — compute SHA-256 for JWT signing input