How to Generate RSA Key Pairs in Go

Generate RSA key pairs in Go using crypto/rand and crypto/rsa packages.

The problem with rolling your own crypto

You are building a service that needs to sign tokens or encrypt payloads. You reach for a cryptography library, generate a key, and suddenly realize the key lives only in memory. The moment the process restarts, the key vanishes. Clients can no longer verify signatures. The system breaks. Generating RSA keys in Go is straightforward, but treating it like a one-liner hides the operational reality. You need to understand where the randomness comes from, how the key structure maps to disk, and why the standard library refuses to make it easier than it already is.

How RSA key generation actually works

RSA relies on the mathematical difficulty of factoring large numbers. The algorithm picks two massive prime numbers, multiplies them together, and publishes the product. Anyone can use that product to encrypt data or verify a signature. Only the holder of the original two primes can decrypt the data or create a valid signature. Go’s crypto/rsa package handles the heavy lifting of prime discovery and modular exponentiation. You do not write the math. You tell the package how large you want the keys to be, and it feeds a cryptographically secure random number generator until it finds primes that meet the size requirement.

Think of it like forging a physical lock. The lock manufacturer rolls two unique tumblers, sets them into a housing, and hands you the housing. The housing is the public key. The exact tumbler measurements are the private key. You can hand out copies of the housing to anyone. You keep the measurements in a safe. Go does not let you pick the tumblers yourself. It forces you to use a certified random source so the lock cannot be picked by guessing.

The standard library splits cryptography into two packages. crypto/rsa defines the key structures and the mathematical operations. crypto/rand provides the entropy. You import both. The separation is intentional. Go keeps algorithm implementations separate from randomness sources so you can swap out the generator if your operating system provides a better one. In practice, you always use rand.Reader. It abstracts away the OS differences between Linux, macOS, and Windows.

The minimal working example

Here is the simplest way to generate a 2048-bit RSA key pair in memory.

package main

import (
	"crypto/rand"
	"crypto/rsa"
	"fmt"
)

func main() {
	// 2048 bits is the current industry standard for RSA.
	// Smaller sizes are considered broken. Larger sizes slow down operations.
	priv, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		// GenerateKey only fails if the random source is exhausted or interrupted.
		// In practice, this means the operating system ran out of entropy.
		panic(err)
	}

	// The public key is embedded inside the private key struct.
	// We extract it by taking the address of the embedded field.
	pub := &priv.PublicKey

	fmt.Printf("Private key modulus length: %d bits\n", priv.N.BitLen())
	fmt.Printf("Public key modulus length: %d bits\n", pub.N.BitLen())
}

What happens under the hood

The code calls rsa.GenerateKey with two arguments. The first is rand.Reader. This is not the math/rand package. math/rand produces predictable sequences based on a seed. Predictable randomness destroys cryptography. crypto/rand reads from the operating system’s entropy pool, which gathers noise from hardware interrupts, disk timings, and network jitter. The second argument is the key size in bits. The function blocks until it finds two primes whose product matches the requested bit length.

When the function returns, priv holds an *rsa.PrivateKey struct. The struct contains the modulus, the public and private exponents, and the prime factors. Go stores the public key inside the private key struct by design. This saves memory and guarantees the pair matches. You extract the public key by referencing &priv.PublicKey. The compiler enforces type safety here. If you try to assign priv.PublicKey directly to a variable typed as *rsa.PublicKey, you get a type mismatch. The address operator fixes it.

Printing the struct with %+v dumps every field, including the raw prime factors. Never log a private key in production. The example only prints the modulus bit length to prove the key exists. Real systems serialize the key to a file or a database before the process exits.

Go’s naming convention applies here. Public types and fields start with a capital letter. Private types and fields start lowercase. The PrivateKey struct is public because you need to pass it around your application. The fields inside it are also public because cryptographic libraries need direct access to the mathematical components. You do not wrap it in getters and setters. Go favors explicit field access over accessor methods.

A realistic server setup

In production, you rarely keep keys in memory forever. You generate them once, save them to disk in PEM format, and load them on startup. PEM stands for Privacy Enhanced Mail, a legacy name for a base64-encoded block wrapped in header and footer lines. Go’s crypto/x509 and encoding/pem packages handle the conversion.

Here is how you generate a key, marshal it to PEM, and write it to a file.

package main

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"os"
)

// savePrivateKey writes an RSA private key to disk in PEM format.
func savePrivateKey(priv *rsa.PrivateKey, path string) error {
	// x509.MarshalPKCS1PrivateKey converts the Go struct to a raw DER byte slice.
	// DER is a binary encoding standard used by X.509 certificates.
	derBytes := x509.MarshalPKCS1PrivateKey(priv)

	// pem.EncodeToBlock wraps the DER bytes in base64 with RSA PRIVATE KEY headers.
	// This produces the text format you see in standard .pem files.
	pemBlock := pem.EncodeToBlock(&pem.Block{
		Type:  "RSA PRIVATE KEY",
		Bytes: derBytes,
	})

	// Write the block to disk with restrictive permissions.
	// 0600 ensures only the owner can read or write the file.
	return os.WriteFile(path, pemBlock, 0600)
}

The public key uses a slightly different marshaling function. x509.MarshalPKIXPublicKey converts it to the SubjectPublicKeyInfo format, which is the standard for public keys. The PEM type changes to PUBLIC KEY. You would follow the same pem.EncodeToBlock and os.WriteFile pattern.

Loading the key back is just the reverse. You read the file, decode the PEM block, and unmarshal the DER bytes into an *rsa.PrivateKey. The x509 package provides ParsePKCS1PrivateKey for this exact step. If the file contains a different format, the parser returns an error instead of panicking. Go’s error handling convention forces you to check that return value. You write if err != nil { return err } on the same line or immediately after. The verbosity is intentional. It makes the failure path impossible to skip. The community accepts the boilerplate because it keeps error handling visible and explicit.

Common traps and compiler warnings

Generating keys is safe, but mishandling them causes silent failures or security holes. The most common mistake is using math/rand instead of crypto/rand. The compiler will not stop you if you import the wrong package. You will get a key that looks valid but can be reversed by an attacker who knows your seed. Always verify the import path.

Another trap is key size selection. Passing 1024 to GenerateKey works without warnings. 1024-bit RSA keys are considered weak by modern standards. Major certificate authorities stopped issuing them years ago. Stick to 2048 for general use or 4096 for long-term archival data. The tradeoff is CPU time. A 4096-bit key takes roughly four times longer to generate and sign than a 2048-bit key. Measure the impact before upgrading.

Memory safety is another concern. Go’s garbage collector reclaims memory, but it does not zero out sensitive buffers before reuse. The private key struct sits in heap memory until the collector runs. An attacker with access to a core dump or a memory scanner could extract the key. The standard library does not provide a Zero() method for RSA keys. If you need guaranteed wiping, you must copy the key bytes to a []byte slice, manually zero the slice, and keep it alive until shutdown. Most applications accept the risk because the key lifetime matches the process lifetime.

If you accidentally pass a negative number or zero to GenerateKey, the function panics immediately. The panic message reads rsa: key size must be >= 1024. The compiler cannot catch this at build time because the size is a runtime argument. You must validate configuration values before calling the generator.

You might also run into format confusion. OpenSSL sometimes outputs keys in PKCS#8 format instead of PKCS#1. Go’s x509 package handles both, but the parsing functions are different. ParsePKCS1PrivateKey expects the traditional format. ParsePKCS8PrivateKey expects the newer wrapper. If you mix them up, the parser returns an error like x509: failed to parse private key. Check the header line in your PEM file. RSA PRIVATE KEY means PKCS#1. PRIVATE KEY means PKCS#8.

When to generate keys versus when to load them

Use rsa.GenerateKey when you need to create a fresh key pair at runtime for testing or dynamic provisioning. Use x509.MarshalPKCS1PrivateKey and pem.EncodeToBlock when you need to persist the key to disk or a secrets manager. Use x509.ParsePKCS1PrivateKey when you are loading an existing key from a file or environment variable. Use crypto/rand for all cryptographic operations and never substitute math/rand. Use 2048 as the default bit length unless compliance requirements mandate 4096. Use PKCS#1 PEM format for private keys and PKIX PEM format for public keys to maintain compatibility with OpenSSL and other tools.

Keys are secrets. Treat them like cash. Generate them once, store them safely, and never log them.

Where to go next