Storing third-party API keys and OAuth tokens in a database is unavoidable for most SaaS backends, but storing them as plaintext is a liability the moment a database backup leaks. AES-256-GCM gives you authenticated encryption with a small, well-understood API surface — this post walks through a production-ready Go pattern for encrypting secrets at rest, plus the mistakes that quietly break it.
Why GCM instead of CBC
AES-CBC needs a separate MAC to detect tampering, and it's easy to forget that step or implement it incorrectly (padding-oracle bugs are a classic result). AES-GCM is an authenticated encryption mode: it produces ciphertext and an authentication tag in one pass, so any bit-flip in storage or transit fails decryption loudly instead of silently. For secrets at rest, that fail-loud property is exactly what you want.
The pattern
The shape that holds up in production is: derive a 32-byte key once, generate a fresh random nonce per encryption call, and prepend the nonce to the ciphertext so you never have to store it separately.
func Encrypt(plaintext, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
// Seal appends ciphertext+tag after the nonce we pass as dst.
sealed := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
Decryption just reverses it: slice off the first gcm.NonceSize() bytes as the nonce, and hand the rest to gcm.Open. A wrong key or corrupted ciphertext returns an error instead of garbage plaintext — you cannot accidentally "decrypt" into nonsense and miss it.
Mistakes that break it
- Reusing a nonce with the same key is the one true GCM sin — it doesn't just weaken the cipher, it lets an attacker recover the XOR of two plaintexts outright. Always generate the nonce with a CSPRNG per call, never a counter you might reset.
- Storing the key next to the ciphertext defeats the entire exercise. The key belongs in a secrets manager or environment variable injected at deploy time, not a config file that ships with the backup.
- Skipping key rotation because "AES-256 is strong enough" ignores that the real risk is usually a leaked key, not a broken cipher. Version your encrypted values so old ciphertext can be re-encrypted under a new key without a flag day.
Wrapping up
AES-256-GCM plus a per-call random nonce prepended to the ciphertext is a boring, well-tested pattern — and boring is exactly what you want for anything touching secrets at rest. If you're rolling this yourself, write a round-trip test that fails loudly on a flipped bit before you trust it with real tokens.
Top comments (0)