By AndyPublished

How to Generate an RSA Key Pair for JWT (and ES256 and Ed25519 Keys) With OpenSSL

To generate an RSA key pair for RS256 JWTs, run 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 algKey typeopenssl command
RS256 / RS384 / RS512RSA, 2048 bits or largergenpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
PS256 / PS384 / PS512RSA, 2048 bits or larger (same key type as RS*)genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
ES256EC on P-256 (prime256v1)genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256
ES384EC on P-384 (secp384r1)genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384
ES512EC on P-521 (secp521r1), not "P-512"genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-521
EdDSA / Ed25519Ed25519 (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.

ℹ
ES256 signatures in a JWT are the raw 64-byte R‖S concatenation, not the DER structure OpenSSL's 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 headerFormatNotes
BEGIN PRIVATE KEYPKCS#8, unencryptedDefault output of openssl genpkey (and genrsa in OpenSSL 3). Works for RSA, EC and Ed25519.
BEGIN ENCRYPTED PRIVATE KEYPKCS#8, password protectedProduced by adding a cipher such as -aes-256-cbc to genpkey.
BEGIN RSA PRIVATE KEYPKCS#1 (RSA only)Traditional format; OpenSSL 1.x genrsa default. Many JOSE libraries reject it.
BEGIN EC PRIVATE KEYSEC 1 (EC only)Output of openssl ecparam -genkey. Convert to PKCS#8 for most JWT libraries.
BEGIN PUBLIC KEYSPKI (X.509 SubjectPublicKeyInfo)The format JWT libraries and jwtdecode.app expect for public keys.
BEGIN RSA PUBLIC KEYPKCS#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.pem

In 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'))); // true

For 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 .gitignore before 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 alg the header names is how algorithm confusion happens.

Summary

  • ·RS256: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048, then openssl pkey -pubout for the public key.
  • ·ES256: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256. Ed25519: openssl genpkey -algorithm ed25519, with alg EdDSA (RFC 8037) or Ed25519 (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 with openssl pkcs8 or openssl rsa.
  • ·Sign and verify a test token before distributing the public key, then publish it as a JWKS with a kid.
Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder