The problem with plaintext secrets
You have a configuration file containing database credentials. You need to store it in a repository, but you do not want anyone reading it. Or you are building a messaging service and want to guarantee that a payload arrives exactly as it was sent, without tampering. Encryption solves both problems. Go ships with a complete cryptography standard library. You do not need third-party packages to handle the heavy lifting. You just need to pick the right algorithm and wire it up correctly.
Symmetric versus asymmetric
Cryptography in Go splits into two families. Symmetric encryption uses the same key to lock and unlock data. It is fast and works well for bulk data. Asymmetric encryption uses a pair of keys. A public key locks data. A private key unlocks it. It solves the problem of sharing secrets over insecure channels, but it is slow and has strict size limits.
Think of symmetric encryption like a physical lock on a filing cabinet. Everyone who needs access gets a copy of the same key. Asymmetric encryption works like a dropbox with a public slot. Anyone can slide a letter through the slot, but only the person with the private key can open the box from the inside.
Go's crypto packages enforce safety by design. The standard library refuses to let you accidentally reuse nonces or skip authentication tags. You will run into compile-time or runtime errors if you try to cut corners. That friction is intentional. Cryptography is unforgiving.
AES-GCM in practice
Here is the simplest way to encrypt a byte slice with AES-GCM. The code generates a random nonce, seals the data, and returns a base64 string.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
)
// EncryptAES locks plaintext using AES-GCM and returns a base64 string.
func EncryptAES(plaintext []byte, key []byte) (string, error) {
// Create the cipher block from the raw key bytes.
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
// Wrap the block in GCM mode for authenticated encryption.
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Allocate a slice for the nonce. GCM requires 12 bytes.
nonce := make([]byte, gcm.NonceSize())
// Fill the nonce with cryptographically secure random data.
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// Seal appends the nonce to the ciphertext and adds an auth tag.
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
The aes.NewCipher call validates the key length. AES only accepts 16, 24, or 32 bytes. If you pass a different size, the function returns an error immediately. The cipher.NewGCM wrapper switches the block cipher into Galois/Counter Mode. GCM handles two jobs at once. It encrypts the data and generates an authentication tag. That tag proves the ciphertext has not been altered.
The nonce is a number used once. It ensures that encrypting the same message twice produces two completely different ciphertexts. The code prepends the nonce to the final output so the decryption function can find it later. The gcm.Seal method takes the nonce, the plaintext, and an optional additional authenticated data slice. It returns a new slice containing the nonce, the ciphertext, and the authentication tag. Base64 encoding turns the binary result into a safe string for storage or transmission.
Decryption and verification
Decryption reverses the process. You decode the base64 string, split off the first 12 bytes as the nonce, and pass the rest to gcm.Open. The Open method verifies the authentication tag before decrypting. If the tag does not match, it fails fast. You never get partially decrypted garbage.
Here is the matching decryption function.
// DecryptAES unlocks a base64-encoded GCM ciphertext and returns plaintext.
func DecryptAES(ciphertextB64 string, key []byte) ([]byte, error) {
// Convert the safe string back into raw bytes.
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return nil, err
}
// Reconstruct the cipher block from the same key.
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Wrap it in GCM mode again for verification.
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Extract the nonce from the beginning of the payload.
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
// Open verifies the auth tag and decrypts in one step.
return gcm.Open(nil, nonce, ciphertext, nil)
}
The gcm.Open call is where the security guarantee lives. It checks the authentication tag first. If an attacker modified a single byte of the ciphertext, the tag verification fails and the function returns an error. The plaintext is never exposed. This is why GCM replaced older modes like CBC in modern Go applications. CBC requires manual padding and separate HMAC verification. GCM bakes both into a single call. The standard library makes the secure path the default path.
The hybrid pattern
Symmetric encryption is fast, but sharing the key is hard. RSA solves the key exchange problem. You generate a public/private key pair, share the public key openly, and keep the private key secret. Anyone can encrypt data with the public key. Only you can decrypt it with the private key.
RSA is computationally expensive and has a hard limit on input size. A 2048-bit key can only encrypt about 245 bytes of data. That is why production systems use a hybrid approach. You generate a random AES key, encrypt the actual payload with AES-GCM, and then encrypt the AES key itself with RSA. The receiver decrypts the AES key with their private key, then uses that key to unlock the payload.
Here is how you generate an RSA key pair and encrypt a small symmetric key.
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
)
// GenerateRSAKeyPair creates a 2048-bit RSA key pair.
func GenerateRSAKeyPair() (*rsa.PrivateKey, error) {
// 2048 bits provides a good balance of security and performance.
return rsa.GenerateKey(rand.Reader, 2048)
}
// EncryptRSAKey locks a symmetric key using the recipient's public key.
func EncryptRSAKey(symKey []byte, pubKey *rsa.PublicKey) ([]byte, error) {
// PKCS1v15 padding is legacy but widely supported.
return rsa.EncryptPKCS1v15(rand.Reader, pubKey, symKey)
}
The rsa.GenerateKey function reads from crypto/rand, not math/rand. The cryptographic reader pulls entropy from the operating system. It blocks until enough randomness is available. That delay is normal. It guarantees that the generated primes are unpredictable. The EncryptPKCS1v15 function applies padding before encryption. Padding prevents mathematical attacks that exploit the deterministic nature of raw RSA. You can swap in EncryptOAEP for stronger security guarantees, but PKCS1v15 remains the baseline for compatibility.
Where things break
Cryptography code breaks in predictable ways. The most common mistake is hardcoding keys. If you embed a key in your source code, the moment you push to version control, the secret is public. Store keys in environment variables, a secrets manager, or a hardware security module. The compiler will not stop you from hardcoding a string. You have to enforce that discipline yourself.
Another trap is ignoring key size validation. AES requires exactly 16, 24, or 32 bytes. Pass a 15-byte string and aes.NewCipher returns an error. The runtime will not guess your intent. If you accidentally truncate a base64 key, you get cipher: invalid key size at runtime. Catch that early by validating input before passing it to the crypto package.
RSA size limits cause silent failures if you are not careful. Try to encrypt a 300-byte payload with a 2048-bit key and the function returns an error. The standard library enforces the mathematical boundary. You will see crypto/rsa: message too long for RSA key size if you push too much data through the asymmetric pipe. Always measure your payload against the key modulus before calling the encryption function.
Nonce reuse is catastrophic for GCM. If you encrypt two different messages with the same key and nonce, an attacker can recover both plaintexts. The crypto/rand package guarantees uniqueness, but caching nonces or deriving them from predictable sources breaks the guarantee. Generate a fresh nonce for every single encryption call.
Go functions that perform cryptographic operations follow a simple naming pattern. The community expects if err != nil { return err } checks after every crypto call. The boilerplate is verbose by design. It forces you to handle failure paths explicitly. You do not get to ignore a failed authentication tag. Trust the error returns. They are your only safety net.
Choosing the right tool
Use AES-GCM when you need to encrypt bulk data and both parties already share a secret key. Use RSA with OAEP padding when you need to exchange a symmetric key over an untrusted network. Use a hybrid encryption pattern when you are building a system that requires both secure key exchange and fast bulk encryption. Use plain text with strong access controls when the data is not sensitive and encryption adds unnecessary latency.
Cryptography is a contract, not a suggestion. Verify tags, rotate keys, and never trust the network.