RSA Key Pair Generation: Fifteen Years of `genpkey` and the Decisions Tutorials Skip

24 April, 2026 Updated: 3 May, 2026 Security

I generated my first RSA key pair in 2009 by running ssh-keygen -t rsa into a terminal, accepting every default, and having absolutely no idea what had just happened mathematically. Fifteen years and thousands of keys later — across TLS certificates, JWT signing infrastructure, code signing pipelines, and encrypted configuration systems — the command is still trivially simple. The decisions surrounding it are not.

Key size, format, storage, rotation, and whether RSA is even the right tool for the job: this is where developers consistently get things wrong. Not because the documentation is missing, but because the stakes of each choice are never made explicit until something breaks.

Here is what you actually need to know.


RSA — named for Rivest, Shamir, and Adleman, who published it in 1977 — is an asymmetric algorithm. Two mathematically linked keys, one public, one private, and the security of the whole system rests on a single assumption: factoring a large composite number back into its two prime components is computationally infeasible. That assumption has held for nearly fifty years against classical computers. It will not hold forever, but we will get to that.

The generation process starts by selecting two large random primes, p and q. For a 2048-bit key, each prime runs roughly 1024 bits — approximately 309 decimal digits. You multiply them to get the modulus n, whose bit length is the key size you specify. You compute Euler's totient, φ(n) = (p-1)(q-1). You select the public exponent e — almost universally 65537, a Fermat prime that balances security and computational efficiency, appearing in JWK output as "e": "AQAB" in Base64url encoding. Then you compute the private exponent d such that d × e ≡ 1 (mod φ(n)).

That is the private key. Anyone who recovers p and q can reconstruct d and forge your signatures or decrypt your messages. The prime factors are the secret. Everything else is arithmetic.

The key generation command feels like a one-liner. The decisions feel that way too, until a root CA key gets compromised, a JWT signing key lives in plaintext in a GitHub repository, or a production system fails because it generated 1024-bit keys in 2024.


Generating keys with OpenSSL starts with one command:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out private.pem

If OpenSSL isn't on the machine you're working from — or you just want to spin up a key pair to test something — the RSA key-pair generator does the same in your browser via Web Crypto API. The private key never leaves the page.

genpkey is the modern replacement for the older genrsa. Both work. The difference is format: genrsa outputs PKCS#1, wrapped with -----BEGIN RSA PRIVATE KEY-----; genpkey outputs PKCS#8, wrapped with -----BEGIN PRIVATE KEY-----. Most modern libraries accept both — except Java's KeyFactory, which expects PKCS#8. Use genpkey for new work. The older command persists in tutorials written before 2015, and copying them without checking the format is how teams end up debugging library incompatibilities at midnight.

Extract the public key with:

openssl pkey -in private.pem -pubout -out public.pem

Verify that a private and public key are actually a matching pair:

openssl pkey -in private.pem -pubout | diff - public.pem

No output means they match. Any output means they do not, and something upstream went wrong.


In the browser, the Web Crypto API provides native RSA generation that delegates to the platform's cryptography library — BoringSSL in Chromium, NSS in Firefox. No data leaves the device.

const keyPair = await crypto.subtle.generateKey(
  {
    name: 'RSASSA-PKCS1-v1_5',
    modulusLength: 3072,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: 'SHA-256',
  },
  true,
  ['sign', 'verify'],
);

const spki  = await crypto.subtle.exportKey('spki',  keyPair.publicKey);
const pkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);

One limitation trips developers up repeatedly: Web Crypto API ties a key to its algorithm at generation time. A key generated for RSASSA-PKCS1-v1_5 cannot be used for RSA-OAEP, even though the underlying key material is mathematically identical. If you need both signing and encryption, generate two separate key pairs, or export as JWK and import into a different context. The generateKey() call is asynchronous because primality testing via Miller-Rabin can run for hundreds of milliseconds on large keys. This is expected behaviour, not a bug.

The RSA key-pair generator wraps exactly this API: pick the algorithm, pick the modulus length, copy the PEM out. Useful for one-off keys when you don't want to wire up the boilerplate yourself.


Across languages, the pattern is consistent: call the platform's key generation function, specify 3072-bit modulus length, output PKCS#8 for the private key and SPKI for the public key.

Node.js:

const { generateKeyPairSync } = require('crypto');

const { publicKey, privateKey } = generateKeyPairSync('rsa', {
  modulusLength: 3072,
  publicKeyEncoding:  { type: 'spki',  format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

Python:

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)

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, err := rsa.GenerateKey(rand.Reader, 3072)

pkcs8Bytes, _ := x509.MarshalPKCS8PrivateKey(privateKey)
privatePem := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8Bytes})

spkiBytes, _ := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
publicPem := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spkiBytes})

PHP:

$keyPair = openssl_pkey_new(['private_key_bits' => 3072, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($keyPair, $privatePem);
$publicPem = openssl_pkey_get_details($keyPair)['key'];

Java:

KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(3072);
KeyPair keyPair = generator.generateKeyPair();
// getPrivate().getEncoded() returns PKCS#8 DER
// getPublic().getEncoded()  returns SPKI DER

Every one of these platforms uses its CSPRNG — a cryptographically secure random number generator — for prime selection. Never substitute your own randomness source. The consequences of weak primes are not abstract: researchers have recovered private keys from millions of RSA certificates by exploiting shared prime factors generated from entropy-starved systems.


Key size is where the most consequential mistakes live. The table is simple; the reasoning behind it matters more.

1024-bit keys are broken. NIST deprecated them in 2013, and factoring attacks on 1024-bit moduli are within reach of well-resourced adversaries. Never generate them.

2048-bit keys provide approximately 112 bits of equivalent security and remain acceptable through 2030, according to NIST SP 800-57. That is not a comfortable runway. Use them only when legacy systems — certain older embedded devices, IoT firmware, or pre-2015 Java environments — cannot handle larger keys.

3072-bit keys provide 128-bit equivalent security, matching AES-128, and satisfy NIST SP 800-57, ANSSI, and BSI guidelines simultaneously. Key generation is a one-time cost. Signing and verification add single-digit milliseconds. The performance argument against 3072-bit keys is not compelling for most workloads. Use this size for new production systems.

4096-bit keys are for root Certificate Authority keys, keys with planned lifespans exceeding ten years, and environments where a regulatory framework explicitly mandates it. Elsewhere, the performance overhead — one to three seconds for key generation, meaningfully slower signing — buys you marginal additional security you are unlikely to need.

Default to 3072-bit. The choice should be deliberate, not inherited from a tutorial written in 2012.


RSA keys are just numbers. The format determines how those numbers are serialized for storage and transmission.

PEM is the most common format: DER-encoded ASN.1 binary data, wrapped in Base64 with 64-character line breaks and header/footer identifiers. The header line — BEGIN PRIVATE KEY, BEGIN RSA PRIVATE KEY, BEGIN CERTIFICATE — identifies the content type. The file extension does not. A .key file, a .pem file, and a .crt file could all contain identical data. Read the header.

JWK — JSON Web Key, defined in RFC 7517 — represents key components as Base64url-encoded JSON fields:

{
  "kty": "RSA",
  "n": "u5D4Xw...",
  "e": "AQAB",
  "d": "Kj2T9...",
  "p": "7gS2...",
  "q": "yQvN..."
}

JWK is the native format for web APIs, OAuth 2.0 JWKS endpoints, OpenID Connect discovery, and JWT libraries. A JWKS — JSON Web Key Set — wraps multiple JWKs in a {"keys": [...]} array. This is how identity providers publish public keys for token verification.

DER is raw binary ASN.1 — identical data to PEM, without the Base64 encoding and header lines. Java's KeyFactory, Windows certificate stores, and some embedded systems expect it. Not human-readable.

Converting between formats:

# PKCS#1 → PKCS#8
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \
  -in private-pkcs1.pem -out private-pkcs8.pem

# PEM → DER
openssl pkey -in private.pem -outform DER -out private.der

# PEM → JWK (Node.js)
node -e "
const fs = require('fs');
const crypto = require('crypto');
const key = crypto.createPrivateKey(fs.readFileSync('private.pem', 'utf8'));
console.log(JSON.stringify(key.export({ format: 'jwk' }), null, 2));
"

SSH is the most common entry point into RSA key generation for most developers. My 2009 self did it without understanding any of it. The command is still familiar:

ssh-keygen -t rsa -b 4096 -C "your_email@example.com"

This generates ~/.ssh/id_rsa and ~/.ssh/id_rsa.pub. Always set a passphrase. Without one, anyone with filesystem access has your key. No warning, no barrier, no friction — just a usable private key sitting in plaintext.

The SSH agent holds your decrypted key in memory so you type the passphrase once:

eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_rsa

On macOS, add this to ~/.ssh/config to integrate with Keychain:

Host *
  AddKeysToAgent yes
  UseKeychain yes
  IdentityFile ~/.ssh/id_rsa

Copy the public key for GitHub:

pbcopy < ~/.ssh/id_rsa.pub    # macOS
xclip -selection clipboard < ~/.ssh/id_rsa.pub  # Linux

Paste it under Settings → SSH and GPG keys → New SSH key.

The more important question is whether you should use RSA at all for new SSH keys. Ed25519 offers equivalent 128-bit security with 256-bit keys instead of 3072+ bits, faster operations, and a cleaner implementation with a smaller attack surface. Generate one with ssh-keygen -t ed25519. Use RSA only when connecting to systems that predate Ed25519 support — OpenSSH versions before 6.5, released in 2014, do not support it. Most systems you interact with today are not running software from 2014.


For JWT signing, RS256 — RSA Signature with SHA-256 — is the most common asymmetric signing algorithm. The reason to choose it over HS256 is separation of concerns. HMAC-based algorithms use a shared secret: both the issuer and every verifier must hold the same key. RSA separates them. Only the auth server holds the private key. Every downstream service verifying tokens holds only the public key.

This matters at scale. Distributing a shared secret to thirty microservices creates thirty potential exposure points. Distributing a public key creates none.

Generate a JWT signing key pair:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out jwt-private.pem
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem

Sign and verify in Node.js:

const jwt = require('jsonwebtoken');
const fs  = require('fs');

const privateKey = fs.readFileSync('jwt-private.pem');
const publicKey  = fs.readFileSync('jwt-public.pem');

const token   = jwt.sign({ sub: '1234', role: 'admin' }, privateKey, { algorithm: 'RS256', expiresIn: '1h' });
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Publish public keys as a JWKS endpoint:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "2026-04-signing-key",
      "use": "sig",
      "alg": "RS256",
      "n": "u5D4Xw...",
      "e": "AQAB"
    }
  ]
}

The kid field in the JWT header tells the verifier which key from the set was used to sign the token. This is the mechanism that makes zero-downtime key rotation possible: add a new key, start signing with it, wait for old tokens to expire, remove the old key. Auth0, Okta, and Keycloak all handle rotation this way.


TLS certificates bind an RSA public key to a domain name through a CA's signature. The process is three steps: generate the key, create a Certificate Signing Request, submit it to a CA.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out server.key
openssl req -new -key server.key -out server.csr \
  -subj "/C=GB/ST=London/O=Your Company/CN=example.com"

Submit the CSR to your CA — Let's Encrypt, DigiCert, Sectigo. They verify domain ownership and return a signed certificate.

For local development only:

openssl req -x509 -new -key server.key -out server.crt -days 365 -subj "/CN=localhost"

Never use self-signed certificates in production. Browsers reject them by design. Use Let's Encrypt for free, automated TLS with ninety-day rotation built in.


Generating the key is the easy part. Protecting it is where most teams fail, and the failure modes are consistent across every code review I have conducted.

Private keys get committed to Git. Even when removed in a later commit, they remain in history — permanently accessible to anyone who clones the repository. Use git-secrets or a pre-commit hook. Not as a nice-to-have. As a hard requirement before the first push.

Private keys get stored in environment variables without encryption. Environment variables appear in process listings, crash dumps, and container inspection output. They feel like configuration. They are secrets.

Private keys get hardcoded in application source. This is the single most common finding in security reviews of codebases that were never reviewed by someone looking for it.

The correct approach by environment:

Environment Solution
AWS Secrets Manager, KMS, Parameter Store (SecureString)
GCP Secret Manager, Cloud KMS
Azure Key Vault
Kubernetes Sealed Secrets, External Secrets Operator, HashiCorp Vault
CI/CD Pipeline secrets (GitHub Actions, GitLab CI)
Development .env files in .gitignore

For maximum protection, use an HSM — Hardware Security Module — where the private key never leaves the hardware device. Cloud providers offer managed HSMs: AWS CloudHSM, GCP Cloud HSM, Azure Dedicated HSM, or KMS services that use HSMs internally.

File permissions on Unix systems:

chmod 600 private.pem
chmod 644 public.pem

OpenSSH refuses to use a private key with permissions looser than 600. This is not a suggestion. It is a hard check.


Key rotation frequency should match the risk profile of the key's function:

Use Case Rotation Frequency
TLS certificates 90 days (Let's Encrypt default)
JWT signing keys 3–12 months
SSH keys Annually
Root CA keys 10–20 years

Zero-downtime JWT rotation follows a specific sequence. Generate a new key pair with a new kid. Add the new public key to your JWKS endpoint alongside the old one. Start signing new tokens with the new key. Wait for old tokens to expire — at minimum, max_token_lifetime. Then remove the old public key. During the overlap window, both keys are valid, and verifiers select the correct one via the kid header in the JWT. The overlap period is not optional. Remove the old key too early and you invalidate unexpired tokens for logged-in users.


RSA is not always the right choice. Elliptic Curve Cryptography provides equivalent security at dramatically smaller key sizes and faster operations.

Property RSA (3072-bit) ECDSA (P-256) Ed25519
Security level 128-bit 128-bit ~128-bit
Private key size ~1.7 KB (PEM) ~230 bytes 64 bytes
Signature size 384 bytes 64 bytes 64 bytes
Sign speed Slower Faster Fastest
Ecosystem support Universal Very broad Broad, growing

For new projects without legacy constraints, Ed25519 is the better default for signing and X25519 for key exchange. For high-throughput signing, mobile, IoT, or any environment where bandwidth and latency matter, ECC wins on every dimension. ES256 — ECDSA with P-256 — is more efficient than RS256 for JWT signing and supported by every major identity library.

RSA remains the right choice when compatibility demands it: older Java environments, legacy embedded systems, certain HSMs, TLS certificates where some intermediate infrastructure still requires RSA, and RSA-OAEP encryption — because ECC does not provide direct encryption, instead relying on ECDH plus a symmetric cipher. Migrating from RSA to ECC is a project with scope and risk, not a single flag change. If you are running RSA in existing infrastructure, that is a reason to plan the migration, not a reason to stay indefinitely.


Shor's algorithm can factor RSA moduli in polynomial time on a sufficiently large quantum computer. That machine does not exist yet. The largest RSA number factored by a quantum device in 2026 is ten bits. The 2048-bit keys in production use are not at immediate risk.

The threat that is real right now is "harvest now, decrypt later." An adversary records encrypted traffic today and decrypts it once quantum capability arrives. For data that must remain confidential for twenty or more years, this is not a hypothetical. It is an active design constraint.

NIST finalised its first post-quantum cryptography standards in 2024: ML-KEM — formerly CRYSTALS-Kyber — for key encapsulation, and ML-DSA — formerly CRYSTALS-Dilithium — for digital signatures. These are not RSA replacements you can swap in today. They require new protocols, new libraries, and infrastructure that does not yet have the operational maturity of RSA.

The practical path forward has three steps: use 3072-bit RSA or ECC for current systems, which are secure against classical computers; monitor PQC library maturity, since OpenSSL 3.5+ and BoringSSL are actively adding support; and design for algorithm agility — PKCS#8 key format, JOSE/JWK key representation, and kid-based key selection — so you can swap algorithms without redesigning your entire authentication infrastructure.

The developers who will handle that transition with the least disruption are the ones who made the agility decisions now, before they were urgent.


Quick reference:

Generate a key pair:

# OpenSSL
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

# SSH
ssh-keygen -t rsa -b 4096 -C "email@example.com"

Inspect a key:

openssl pkey -in private.pem -text -noout
openssl pkey -in public.pem -pubin -outform DER | openssl dgst -sha256

Convert formats:

# PKCS#1 → PKCS#8
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

# PEM → DER
openssl pkey -in private.pem -outform DER -out private.der

# Extract public from private
openssl pkey -in private.pem -pubout -out public.pem

RSA key generation takes thirty seconds to learn and a career to get right. The command is simple. The judgment around it — which size, which format, where it lives, when it rotates, and whether RSA is even the correct primitive for the job — is where the actual security of your system is decided. Most of those decisions get made by default, by habit, or by copying a five-year-old Stack Overflow answer. The ones that get made deliberately are the ones that hold.

More Articles

Diceware Passphrases: Why I Stopped Memorising Random Strings

A practical guide to diceware passphrases for developers. Covers the EFF Large word list, entropy math, separator and capitalisation trade-offs, real use cases (master passwords, FDE, SSH keys), common mistakes, and code examples in PHP, Python, and JavaScript.

18 May, 2026

Rich Text to Markdown: How to Convert Google Docs, Word, and Notion Cleanly

Practical guide to converting rich text and HTML to clean Markdown. What survives, what breaks, source-specific quirks, and how to clean up the output.

1 May, 2026

HTML, CSS and JavaScript Minification: Complete Guide to Benefits, Risks and Best Practices

A developer's guide to minifying HTML, CSS, and JavaScript. Covers what minifiers actually remove, real size savings, common breakage patterns, source maps, and when minification matters vs when it is a waste of time.

30 April, 2026