How to Encrypt Data with RSA in Go
You are building a service that needs to share a secret with another service. You generate an RSA key pair. You take the recipient's public key and encrypt a database password. It works. You try to encrypt a 50-megabyte log file. The program panics.
RSA is not a zip file for secrets. It is a mathematical lock designed for small payloads. Using RSA to encrypt large data is like using a padlock to secure a shipping container. The lock breaks, or the mechanism jams, because the design has a hard limit.
The standard pattern in Go is to use RSA to encrypt a symmetric key, then use that symmetric key to encrypt the actual data. This hybrid approach gives you the key exchange benefits of RSA with the speed and capacity of symmetric encryption.
The lock and the key
RSA is asymmetric encryption. You have two keys: a public key and a private key. The public key can encrypt data. Only the private key can decrypt it. The math relies on the difficulty of factoring large prime numbers. If you have the public key, you cannot derive the private key in any reasonable amount of time.
Think of the public key as a padlock that anyone can snap shut. You mail the open padlock to a friend. They put a message in a box, snap the padlock, and mail it back. Only you have the key to open the padlock. Even the friend cannot open it.
Go's crypto/rsa package implements this math. You generate a key pair, encrypt with the public key, and decrypt with the private key. The package handles the heavy lifting. Your job is to pick the right padding scheme and respect the size limits.
Padding is the difference between secure and broken
Raw RSA encryption is deterministic. The same plaintext always produces the same ciphertext. This leaks information. An attacker can guess common messages, encrypt them with the public key, and compare the result to the intercepted ciphertext. If they match, the attacker knows the message.
Padding fixes this. Padding adds random data to the plaintext before encryption. The same message encrypts to different ciphertext every time. Go supports two padding schemes: PKCS#1 v1.5 and OAEP.
PKCS#1 v1.5 is the older standard. It has known vulnerabilities against chosen-ciphertext attacks. The Bleichenbacher attack allows an attacker to recover the plaintext by observing decryption errors. Use PKCS#1 v1.5 only when you must interoperate with legacy systems that cannot support OAEP.
OAEP (Optimal Asymmetric Encryption Padding) is the modern standard. It binds the padding to a hash of the message and a random mask. It provides strong security guarantees. Always use OAEP for new applications. The rsa.EncryptOAEP and rsa.DecryptOAEP functions implement this scheme.
OAEP requires a hash function. SHA-256 is the standard choice. You pass a hash instance to the encryption function. The hash function influences the padding structure and the size limit.
Minimal example
Here is the core loop: generate a key, encrypt a short message with OAEP padding, and decrypt it back.
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"fmt"
)
func main() {
// 2048 bits is the minimum secure size. Smaller keys are breakable.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
// OAEP padding prevents mathematical attacks on the raw encryption.
// Max plaintext is ~190 bytes for this key size and hash.
msg := []byte("Short secret")
cipher, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &priv.PublicKey, msg, nil)
if err != nil {
panic(err)
}
// Decryption reverses the process using the private key.
plain, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, cipher, nil)
if err != nil {
panic(err)
}
fmt.Println(string(plain))
}
Key generation is expensive. rsa.GenerateKey performs heavy modular arithmetic and reads from the system's cryptographically secure random number generator. It blocks until it finds suitable primes. Do not generate keys on every request. Generate them once, persist them, and load them at startup.
The if err != nil pattern is verbose by design. Go makes the unhappy path visible. You cannot accidentally ignore an error. The compiler forces you to handle it. This boilerplate is a feature. It prevents silent failures in security-critical code.
RSA is for keys, not data. Encrypt the key, not the payload.
What happens under the hood
When you call rsa.EncryptOAEP, the function performs several steps. It generates a random seed and a random mask. It hashes the label (if provided) and the seed. It XORs the mask with the hash to create the padded message. It encrypts the padded message using the public key exponentiation. The output is a byte slice the same length as the key size.
For a 2048-bit key, the ciphertext is always 256 bytes. The plaintext must be shorter. The size limit depends on the key size, the hash function, and the label length. The formula is roughly keyBytes - 2 * hashSize - 2. For a 2048-bit key and SHA-256, the limit is 190 bytes.
If you pass a message that is too long, the function returns an error. The compiler does not catch this. It is a runtime check. The error message is crypto/rsa: message too long for 2048-bit key.
Decryption reverses the process. It decrypts the ciphertext, un-pads the message, verifies the hash, and returns the plaintext. If the ciphertext is malformed or the padding check fails, decryption returns an error. It does not return garbage data. This prevents oracle attacks where an attacker learns information from partial decryption results.
The rand.Reader argument provides the randomness for the padding. It reads from the operating system's CSPRNG. Never substitute a deterministic random source. Predictable padding breaks the security model.
Trust the standard library. Do not implement your own padding or random generation.
Hybrid encryption for real payloads
Real systems encrypt large payloads with hybrid encryption. You generate a random symmetric key, encrypt the data with that key, and then encrypt the symmetric key with RSA. The recipient decrypts the symmetric key with their private key, then decrypts the data with the symmetric key.
This pattern is used in TLS, PGP, and almost every secure file transfer protocol. RSA protects the key. AES protects the data.
Here is how to wrap a symmetric key with RSA.
// wrapKey generates a random AES-256 key and encrypts it with RSA.
func wrapKey(pub *rsa.PublicKey) ([]byte, []byte, error) {
// AES-256 requires a 32-byte key.
aesKey := make([]byte, 32)
_, err := rand.Read(aesKey)
if err != nil {
return nil, nil, err
}
// Encrypt the small AES key with RSA.
// This output is ~256 bytes, well within the RSA limit.
encryptedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, pub, aesKey, nil)
if err != nil {
return nil, nil, err
}
return aesKey, encryptedKey, nil
}
The symmetric key is small. 32 bytes fits easily within the RSA limit. You can encrypt it safely. The encrypted key is the same size as the RSA key. You can store it alongside the encrypted data.
Here is how to seal the data with AES-GCM.
// sealData encrypts data with AES-GCM using the symmetric key.
func sealData(key, data []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// GCM provides authenticated encryption.
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Nonce must be unique per encryption operation.
nonce := make([]byte, gcm.NonceSize())
_, err = rand.Read(nonce)
if err != nil {
return nil, err
}
// Seal returns nonce + ciphertext.
return gcm.Seal(nil, nonce, data, nil), nil
}
AES-GCM is an authenticated encryption mode. It ensures the data has not been tampered with. The nonce must be unique for every encryption with the same key. Reusing a nonce breaks GCM security. Generate a random nonce for each operation.
The Seal method appends the ciphertext to the destination slice. Passing nil allocates a new slice. The output includes the nonce prepended to the ciphertext. This makes storage simple. You can store the nonce, encrypted key, and encrypted data in a single blob.
Hybrid encryption is the standard. RSA protects the key; AES protects the data.
Pitfalls and errors
RSA encryption has specific failure modes. Understanding them prevents subtle bugs.
The most common error is exceeding the size limit. If you pass a message that is too long to rsa.EncryptOAEP, you get crypto/rsa: message too long for 2048-bit key. This is a runtime error. The compiler does not check message length. Always validate the input size or use hybrid encryption for variable-length data.
Using PKCS#1 v1.5 is a security risk. The rsa.EncryptPKCS1v15 function exists for compatibility. It does not provide the same security guarantees as OAEP. If you see rsa.EncryptPKCS1v15 in a codebase, treat it as a technical debt item. Migrate to OAEP unless you have a documented reason to keep the legacy scheme.
Key persistence requires care. RSA keys are large binary structures. You should serialize them to PEM format for storage. Use x509.MarshalPKCS1PrivateKey for the private key and x509.MarshalPKIXPublicKey for the public key. Wrap the result with pem.EncodeToMemory.
import (
"crypto/x509"
"encoding/pem"
)
// Marshal the private key to PKCS#1 binary format.
derBytes, err := x509.MarshalPKCS1PrivateKey(priv)
if err != nil {
panic(err)
}
// Wrap in PEM for text-based storage.
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: derBytes,
})
Never hardcode private keys in source code. Load keys from environment variables, secret managers, or secure file stores at runtime. Rotate keys regularly. The context.Context convention applies here too. If your key loading function takes time, accept a context as the first parameter and respect cancellation.
OAEP is the only safe choice for new code. PKCS#1 v1.5 is legacy.
When to use RSA
Pick the right tool for the job. RSA has a specific role in the encryption ecosystem.
Use RSA encryption when you need to exchange a small secret with a party that only has your public key. Use AES encryption when you need to protect large volumes of data efficiently. Use hybrid encryption when you need to send large data securely to a recipient identified by an RSA public key. Use PKCS#1 v1.5 only when maintaining compatibility with legacy systems that cannot support OAEP.
The decision matrix is simple. RSA handles key exchange. AES handles data protection. Hybrid combines them. Do not use RSA for bulk data. Do not use AES for key exchange without a secure channel.
Pick the tool for the job. RSA for keys, AES for data, hybrid for the rest.