How to Encrypt Data with AES in Go (GCM Mode)

Encrypt data in Go using AES-GCM by creating a cipher from a key and sealing plaintext with a random nonce.

You built a CLI tool that saves credentials

You built a command-line tool that saves database credentials to a JSON file. It works perfectly. Then you push the code to a shared repository, or you run the tool on a multi-tenant server. The credentials are sitting there in plaintext. Anyone with read access to the disk can steal them. You need to encrypt the file. You want the data to look like random noise to anyone without the key, and you want to know immediately if someone modifies the file. Go's standard library provides AES-GCM, which handles both encryption and integrity checks in one step.

Authenticated encryption with a stamp

AES is the Advanced Encryption Standard. It is a block cipher that takes a fixed-size chunk of data and a key, then scrambles the chunk. GCM stands for Galois/Counter Mode. It turns the block cipher into a stream cipher and adds authentication. The result is an AEAD scheme: Authenticated Encryption with Associated Data.

AEAD ensures confidentiality and integrity. If you use a mode like CBC, you get encryption but no integrity. An attacker can flip bits in the ciphertext and change the plaintext without knowing the key. GCM prevents this. Every decryption attempt verifies a mathematical tag. If the tag does not match, the function fails. You never get partial or corrupted data.

Think of a sealed envelope with a wax stamp. The wax hides the letter inside. The stamp proves no one opened it. If the stamp is broken, you discard the letter immediately. GCM gives you that stamp for free. You do not need to compute a separate MAC or worry about padding oracles. The library handles the math.

GCM gives you a wax stamp. If the stamp is broken, discard the message.

The minimal encryption function

Here is the core encryption function. It takes a key and plaintext, generates a nonce, and returns the ciphertext. The code follows Go conventions: explicit error handling, short variable names, and efficient slice reuse.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
)

// Encrypt takes a key and plaintext, returning ciphertext or an error.
func Encrypt(key, plaintext []byte) ([]byte, error) {
	// Create the AES block cipher from the key.
	// The key must be 16, 24, or 32 bytes.
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	// Wrap the block in GCM mode for authenticated encryption.
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	// Allocate a buffer for the nonce.
	// GCM requires a unique nonce for every encryption with the same key.
	nonce := make([]byte, gcm.NonceSize())

	// Fill the nonce with cryptographically secure random bytes.
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}

	// Seal encrypts the plaintext and appends the authentication tag.
	// Passing nonce as the destination reuses the buffer to avoid allocation.
	// The result contains nonce || ciphertext || tag.
	return gcm.Seal(nonce, nonce, plaintext, nil), nil
}

The function starts by creating an AES block cipher. The key must be 16, 24, or 32 bytes long. These sizes correspond to AES-128, AES-192, and AES-256. AES-256 is the standard choice for new systems. The compiler treats the key as a []byte, so it cannot check the size at compile time. You must handle the error at runtime. If you pass a 10-byte key, the function returns crypto/aes: invalid key size 10.

cipher.NewGCM wraps the block. It returns a cipher.AEAD interface. This interface exposes Seal and Open. GCM requires a nonce. The nonce is a number used once. It ensures that encrypting the same plaintext twice produces different ciphertexts. If you reuse a nonce with the same key, the encryption breaks completely. The nonce does not need to be secret, but it must be unique.

rand.Read fills the nonce buffer. This function draws from the operating system's cryptographic random source. On Linux, it reads from /dev/urandom. It blocks if the system runs low on entropy, which is rare on modern systems. The nonce size for GCM is typically 12 bytes. You can query this with gcm.NonceSize().

gcm.Seal performs the encryption. The signature is Seal(dst, nonce, plaintext, aad []byte). The dst argument is a destination buffer. Passing nonce as dst is a common optimization. Seal writes the nonce into dst, then appends the ciphertext and the authentication tag. The result is a single slice containing nonce || ciphertext || tag. This layout makes storage simple. You save one blob and recover everything you need later.

The nonce is unique. The key is secret. The tag is the proof.

Decryption and verification

Encryption is half the battle. You need to decrypt the data to use it. The decryption function reverses the process, splitting the nonce from the ciphertext and verifying the tag.

// Decrypt takes a key and ciphertext, returning plaintext or an error.
func Decrypt(key, ciphertext []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	// The nonce size is fixed for GCM.
	// Split the ciphertext to extract the nonce.
	nonceSize := gcm.NonceSize()
	if len(ciphertext) < nonceSize {
		return nil, fmt.Errorf("ciphertext too short")
	}

	// Separate the nonce and the encrypted data.
	// The encrypted data includes the authentication tag.
	nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]

	// Open decrypts and verifies the authentication tag.
	// If the tag does not match, the data was tampered with or the key is wrong.
	return gcm.Open(nil, nonce, ciphertext, nil)
}

gcm.Open verifies the tag. If the key is wrong or the data is tampered, Open returns an error. The error message is generic. It returns cipher: message authentication failed. This is a security feature. If the error distinguished between "wrong key" and "tampered data", an attacker could probe the system to guess the key. The generic error prevents side-channel attacks.

The if err != nil pattern appears everywhere. It looks repetitive, but it forces you to handle failure explicitly. In encryption, ignoring an error can mean you encrypted with a bad key or decrypted tampered data. The verbosity keeps the unhappy path visible. Go does not have exceptions. You must check the error or the program panics.

gofmt is mandatory. The code follows standard formatting. Indentation is tabs. Braces are on the same line. Run gofmt to enforce this. The community expects this style. Most editors run it on save. Do not argue about indentation; let the tool decide.

Wrong key and tampered data look identical. That is how you keep the key safe.

Associated data for context

The Seal and Open functions accept an extra slice called AAD. Associated Authenticated Data. This is data you want to verify but not hide. For example, a message header that says "Invoice #123". You encrypt the amount, but you authenticate the header so no one swaps the invoice number. Pass the header to the fourth argument of Seal. If you omit it, pass nil.

// EncryptWithAAD encrypts plaintext and binds it to associated data.
func EncryptWithAAD(key, plaintext, aad []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}

	// Seal includes aad in the authentication tag calculation.
	// The aad is not encrypted, but tampering with it causes decryption to fail.
	return gcm.Seal(nonce, nonce, plaintext, aad), nil
}

AAD lets you bind the ciphertext to context without hiding the context. If you store a record with a public ID and a secret value, you can pass the ID as AAD. The encryption covers the value, but the tag covers both. If someone swaps the ID with another record, the tag verification fails. This prevents record shuffling attacks.

If you wrap this logic in a struct, use a short receiver name. (e *Encryptor) Seal(...). Not (this *Encryptor) or (self *Encryptor). The receiver name is usually one or two letters matching the type. This is a Go convention that keeps method signatures clean.

Pitfalls and security rules

Key management is the most critical part. If the key is compromised, the encryption is useless. Never hardcode keys. Use environment variables, a secrets manager, or a key derivation function. If you derive a key from a password, use a function like scrypt or argon2. Raw passwords are not suitable for AES keys. They lack entropy and are vulnerable to brute force.

Nonce reuse breaks security. Never reuse a nonce with the same key. Generate a fresh nonce for every encryption operation. Random nonces are safe. Counter-based nonces are also safe if you manage the counter correctly, but random is easier in Go. The probability of collision with 12-byte random nonces is negligible for realistic usage.

Key size matters. Stick to 32 bytes. AES-256 provides a large security margin. AES-128 is still secure, but AES-256 is the default expectation for new applications. If you pass a key of the wrong size, aes.NewCipher returns an error. Handle it. Do not ignore it.

Do not use encryption for passwords. Encryption is reversible. If you store passwords, use a hash function like bcrypt or argon2. Hashing is one-way. You cannot recover the password from the hash. Encryption allows recovery, which defeats the purpose of password storage.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. This rule applies to concurrency, but the discipline carries over. If you run encryption in a background goroutine, ensure it can stop. Use context.Context to signal cancellation. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.

The worst encryption bug is the one that silently accepts tampered data. Always check the error from Open.

Decision matrix

Use AES-GCM when you need to encrypt data at rest and your hardware supports AES-NI instructions.

Use ChaCha20-Poly1305 when you are deploying to devices without hardware AES acceleration, such as some mobile chips or older embedded processors.

Use a key derivation function like scrypt when your secret source is a human-memorable password.

Use a hash function like bcrypt when storing user passwords; encryption allows recovery, which defeats the purpose of password storage.

Use TLS for network communication; application-level encryption does not replace transport security.

Use plain sequential code when you do not need concurrency; the simplest thing that works is usually the right thing.

Pick the cipher that matches your hardware and threat model. AES-GCM is fast on modern CPUs.

Where to go next