By AndyPublished
How to Generate an RSA Key Pair for JWT (and ES256 and Ed25519 Keys) With OpenSSL
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem and then extract the public key with openssl pkey -in private.pem -pubout -out public.pem. The issuer signs with private.pem; every verifier gets public.pem.This guide gives the equivalent commands for ES256 (P-256) and Ed25519, explains the PEM headers you will meet (PKCS#8, PKCS#1, SPKI) and how to convert between them, and ends with a Node.js script that signs and verifies a test token so you know the pair works before it reaches production. Every command was run against OpenSSL 3.0.
Which Key Type Does Each JWT Algorithm Need?
The alg header fixes the key type. RFC 7518 §3.3 requires RSA keys of 2048 bits or larger for RS* and PS* algorithms, and §3.4 binds each ES* algorithm to exactly one curve. All commands in the table are prefixed with openssl and write a PKCS#8 private key.
| JWT alg | Key type | openssl command |
|---|---|---|
| RS256 / RS384 / RS512 | RSA, 2048 bits or larger | genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 |
| PS256 / PS384 / PS512 | RSA, 2048 bits or larger (same key type as RS*) | genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 |
| ES256 | EC on P-256 (prime256v1) | genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 |
| ES384 | EC on P-384 (secp384r1) | genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 |
| ES512 | EC on P-521 (secp521r1), not "P-512" | genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-521 |
| EdDSA / Ed25519 | Ed25519 (OKP) | genpkey -algorithm ed25519 |
If you are still deciding between a shared secret and a key pair, HS256 vs RS256 covers that choice. If you only need an HMAC secret, the JWT secret key generator is the page you want, not this one.
How to Generate an RSA Key Pair for JWT (RS256)
# 1. Private key (PKCS#8, "BEGIN PRIVATE KEY") openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rs256-private.pem # 2. Public key (SPKI, "BEGIN PUBLIC KEY") openssl pkey -in rs256-private.pem -pubout -out rs256-public.pem # 3. Lock down the private key chmod 600 rs256-private.pem # Optional: inspect it openssl pkey -in rs256-private.pem -noout -text | head -1 # Private-Key: (2048 bit, 2 primes)
2048 bits is the RFC minimum and remains the most common size. 3072 bits is a reasonable choice for keys that will live for many years; the costs are larger signatures (384 bytes instead of 256) and slower signing. The same key pair works for PS256, which uses RSA-PSS padding instead of PKCS#1 v1.5; see PS256 vs RS256 for when that matters.
To keep the private key encrypted at rest, add a cipher: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -aes-256-cbc -out private.pem prompts for a passphrase and writes BEGIN ENCRYPTED PRIVATE KEY. Your application then needs the passphrase at start-up, so in practice most teams keep the key unencrypted inside a secrets manager or use a KMS/HSM that never exposes it.
JWT ES256 Key: Generate a P-256 Key Pair
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out es256-private.pem openssl pkey -in es256-private.pem -pubout -out es256-public.pem openssl pkey -in es256-private.pem -noout -text | grep -E "ASN1 OID|NIST CURVE" # ASN1 OID: prime256v1 # NIST CURVE: P-256
Older tutorials use openssl ecparam -name prime256v1 -genkey -noout -out key.pem. That works but writes a SEC 1 key (BEGIN EC PRIVATE KEY), which jose's importPKCS8 and the Rust jsonwebtoken crate (used by jwt-cli) reject. Convert it with openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem, or just use genpkey from the start.
dgst -sign emits. JWT libraries handle this for you; hand-rolled signing code usually gets it wrong. ES256 explained has the details.EdDSA: Generate an Ed25519 Key for JWT
openssl genpkey -algorithm ed25519 -out ed25519-private.pem openssl pkey -in ed25519-private.pem -pubout -out ed25519-public.pem cat ed25519-public.pem # -----BEGIN PUBLIC KEY----- # MCowBQYDK2VwAyEA... (60 base64 characters: a 12-byte header plus the 32-byte key) # -----END PUBLIC KEY-----
RFC 8037 introduced Ed25519 to JOSE with the algorithm identifier "alg": "EdDSA" and the JWK key type OKP with "crv": "Ed25519". RFC 9864 (October 2025) deprecates the polymorphic EdDSA identifier, because it does not say which curve is in use, and registers the fully specified "alg": "Ed25519" instead. Current jose (6.x) signs and verifies both. Older libraries and many identity providers only know EdDSA, and plenty of JWT stacks do not support Ed25519 at all, so check every verifier before choosing it.
PKCS#8 vs PKCS#1: "BEGIN RSA PUBLIC KEY" vs "BEGIN PUBLIC KEY"
The first line of a PEM file tells you the structure inside it, and JWT libraries are strict about it. A public key that begins -----BEGIN RSA PUBLIC KEY----- holds only the RSA modulus and exponent (PKCS#1). One that begins -----BEGIN PUBLIC KEY----- wraps the key in an SPKI structure that also names the algorithm. Web Crypto, jose and most verifiers want SPKI.
| PEM header | Format | Notes |
|---|---|---|
| BEGIN PRIVATE KEY | PKCS#8, unencrypted | Default output of openssl genpkey (and genrsa in OpenSSL 3). Works for RSA, EC and Ed25519. |
| BEGIN ENCRYPTED PRIVATE KEY | PKCS#8, password protected | Produced by adding a cipher such as -aes-256-cbc to genpkey. |
| BEGIN RSA PRIVATE KEY | PKCS#1 (RSA only) | Traditional format; OpenSSL 1.x genrsa default. Many JOSE libraries reject it. |
| BEGIN EC PRIVATE KEY | SEC 1 (EC only) | Output of openssl ecparam -genkey. Convert to PKCS#8 for most JWT libraries. |
| BEGIN PUBLIC KEY | SPKI (X.509 SubjectPublicKeyInfo) | The format JWT libraries and jwtdecode.app expect for public keys. |
| BEGIN RSA PUBLIC KEY | PKCS#1 public key (RSA only) | Not SPKI. Convert before using it with jose, Web Crypto or most verifiers. |
Converting between formats
# PKCS#1 RSA public key -> SPKI ("BEGIN PUBLIC KEY")
openssl rsa -RSAPublicKey_in -in rsa-pub-pkcs1.pem -pubout -out public.pem
# SPKI -> PKCS#1 RSA public key (for tools that insist on it)
openssl rsa -pubin -in public.pem -RSAPublicKey_out -out rsa-pub-pkcs1.pem
# PKCS#1 or SEC 1 private key -> PKCS#8 ("BEGIN PRIVATE KEY")
openssl pkcs8 -topk8 -nocrypt -in legacy-private.pem -out private.pem
# PKCS#8 RSA private key -> PKCS#1 ("BEGIN RSA PRIVATE KEY")
openssl rsa -in private.pem -traditional -out rsa-pkcs1.pemIn OpenSSL 3, openssl pkey -in legacy-private.pem -out private.pem also rewrites any private key as PKCS#8. If a library reports that a key "must be PKCS#8 formatted" or "must be SPKI formatted", one of these conversions is almost always the fix.
Sign and Verify a Test Token in Node.js
Before handing the public key to other teams, prove the pair works. This script uses jose (npm install jose) and loops over the three key pairs generated above:
// verify-keys.mjs
import { readFileSync } from 'node:fs';
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
const pairs = [
['RS256', 'rs256-private.pem', 'rs256-public.pem'],
['ES256', 'es256-private.pem', 'es256-public.pem'],
['EdDSA', 'ed25519-private.pem', 'ed25519-public.pem'],
];
for (const [alg, priv, pub] of pairs) {
const privateKey = await importPKCS8(readFileSync(priv, 'utf8'), alg);
const publicKey = await importSPKI(readFileSync(pub, 'utf8'), alg);
const token = await new SignJWT({ sub: 'user-123' })
.setProtectedHeader({ alg, kid: 'test-key-1' })
.setIssuedAt()
.setIssuer('https://issuer.example')
.setAudience('api')
.setExpirationTime('10m')
.sign(privateKey);
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'https://issuer.example',
audience: 'api',
});
console.log(alg, 'verified, sub =', payload.sub);
}If you prefer no dependencies, node:crypto can do RS256 directly. This is useful for understanding what the library does, though a maintained library should do the real verification, including claim checks:
import { readFileSync } from 'node:fs';
import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const signingInput = `${b64u({ alg: 'RS256', typ: 'JWT' })}.${b64u({ sub: 'user-123' })}`;
const signature = sign('sha256', Buffer.from(signingInput),
createPrivateKey(readFileSync('rs256-private.pem')));
const token = `${signingInput}.${signature.toString('base64url')}`;
const [h, p, s] = token.split('.');
console.log(verify('sha256', Buffer.from(`${h}.${p}`),
createPublicKey(readFileSync('rs256-public.pem')), Buffer.from(s, 'base64url'))); // trueFor ES256 with node:crypto, pass dsaEncoding: 'ieee-p1363' in the key object for both sign and verify; the default is DER, which produces a roughly 70-byte signature instead of the 64 bytes JWT requires.
You can also check a token by hand: paste it into the jwtdecode.app decoder, open the Verify tab and paste the contents of the public key file. The decoder verifies RS, PS and ES algorithms with a PEM public key in SPKI form (BEGIN PUBLIC KEY) using Web Crypto in your browser. It does not accept PKCS#1 public keys, JWKs or Ed25519, and it never needs the private key.
Should You Use an Online JWT RS256 Key Generator?
For throwaway test keys, an online generator is convenient. For anything that will sign real tokens, no: the private key is the one secret an asymmetric scheme depends on, and a web page that generated it has had the chance to keep a copy. Local OpenSSL takes a second, works offline and leaves nothing behind. If you do use a browser tool for testing, prefer one that generates keys with Web Crypto locally and label the resulting keys clearly so they never drift into a real configuration.
Publishing the Public Key as a JWKS
Verifiers that fetch keys automatically expect a JSON Web Key Set (RFC 7517), usually served at /.well-known/jwks.json. jose converts a PEM public key to a JWK and can derive a stable kid from the RFC 7638 thumbprint:
import { readFileSync } from 'node:fs';
import { importSPKI, exportJWK, calculateJwkThumbprint } from 'jose';
const publicKey = await importSPKI(readFileSync('es256-public.pem', 'utf8'), 'ES256');
const jwk = await exportJWK(publicKey);
jwk.kid = await calculateJwkThumbprint(jwk);
jwk.alg = 'ES256';
jwk.use = 'sig';
console.log(JSON.stringify({ keys: [jwk] }, null, 2));Put the same kid in the header of every token you sign so verifiers can pick the right key during rotation. The JWKS guide and the kid header guide cover caching and rotation.
Protecting the Private Key
- ·Generate the key where it will be used, or inside a KMS/HSM that signs on your behalf, so it never travels through laptops or chat.
- ·Keep one key per environment. A staging key must not be able to sign production tokens.
- ·Never commit
*-private.pem; add it to.gitignorebefore generating. - ·Rotate on a schedule by publishing the new public key first, switching the signer, then retiring the old key once its tokens have expired.
- ·Pin the accepted algorithms in every verifier. Accepting whatever
algthe header names is how algorithm confusion happens.
Summary
- ·RS256:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048, thenopenssl pkey -puboutfor the public key. - ·ES256:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256. Ed25519:openssl genpkey -algorithm ed25519, withalgEdDSA(RFC 8037) orEd25519(RFC 9864). - ·Private keys should be PKCS#8 (
BEGIN PRIVATE KEY) and public keys SPKI (BEGIN PUBLIC KEY); convert legacy PKCS#1 and SEC 1 files withopenssl pkcs8oropenssl rsa. - ·Sign and verify a test token before distributing the public key, then publish it as a JWKS with a
kid.