Encrypting secrets in Go: from DIY crypto to the right primitives

Article
🇫🇷 This article is also available in Français

A standard SaaS accumulates secrets on behalf of its users: Stripe API keys, webhook secrets, OAuth tokens. These live in your database. When that database is compromised — undetected SQL injection, misconfigured S3 snapshot, access never revoked after someone left — every single one of your customers’ credentials goes with it.

type Account struct {
    UserID         int
    StripeAPIKey   string // ⚠️ Sensitive
    WebhookSecret  string // ⚠️ Sensitive
}

The obvious answer: encrypt these fields before writing to the database. The question that immediately follows: how? And that’s where most developers make choices that feel reasonable but really aren’t.

The naive approach: AES-GCM with SHA256

The first instinct — usually inspired by a Stack Overflow snippet or a blog post — looks something like this:

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "crypto/sha256"
    "encoding/hex"
    "io"
)

func deriveKey(passphrase string) []byte {
    hash := sha256.Sum256([]byte(passphrase))
    return hash[:]
}

func encrypt(passphrase, plaintext string) (string, error) {
    key := deriveKey(passphrase)

    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 := io.ReadFull(rand.Reader, nonce); err != nil {
        return "", err
    }

    ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
    return hex.EncodeToString(ciphertext), nil
}

func decrypt(passphrase, ciphertextHex string) (string, error) {
    key := deriveKey(passphrase)

    ciphertext, err := hex.DecodeString(ciphertextHex)
    if err != nil {
        return "", err
    }

    block, err := aes.NewCipher(key)
    if err != nil {
        return "", err
    }

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

    nonceSize := gcm.NonceSize()
    nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]

    plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
    if err != nil {
        return "", err
    }

    return string(plaintext), nil
}

AES-GCM is a solid symmetric primitive — authenticated, battle-tested, well-supported in Go. That’s not the problem. The problem is everything around it.

Problem 1: SHA256 is not a KDF

SHA256 is a hash function. Its entire design goal is to be as fast as possible while remaining collision-resistant. For validating a certificate or hashing a 4 GB file, that’s exactly what you want. For deriving a cryptographic key from a human-chosen passphrase, it’s a disaster.

A proper Key Derivation Function (KDF) is intentionally slow and resource-intensive. SHA256 has none of that — it was optimized in the exact opposite direction.

Problem 2: No salt

deriveKey("my-super-password") will always return the same output. It’s deterministic by design.

The direct consequence: two accounts using the same passphrase to encrypt their data produce the same AES key. An attacker who dumps your database can immediately spot duplicates. They only need to crack one entry to decrypt all identical ones — in a single pass. Without a salt, rainbow tables (precomputed maps from common SHA256 hashes back to their inputs) are also directly exploitable.

Problem 3: No versioning in the payload

The format this code produces is nonce || ciphertext, hex-encoded. No metadata. No indication of the derivation algorithm, the parameters used, or the format version.

Six months from now you want to migrate to a proper KDF. How do you tell your old records (SHA256) from new ones (Argon2id) apart? You can’t — unless you keep an external registry or migrate everything blindly. This kind of technical debt always gets paid back with interest.

Problem 4: Memory management

Minor but real: the passphrase and derived key stick around in memory as strings until the next GC, potentially longer. A crash dump, a heap vulnerability, or an accidentally enabled coredump exposes them. Not the most likely attack vector, but on critical data, it counts.

The attack chain on a leaked database

sequenceDiagram
    participant A as Attacker
    participant DB as Leaked database
    participant GPU as GPU Farm

    A->>DB: Fetch encrypted fields
    DB-->>A: List of ciphertexts
    A->>A: Spot duplicates<br/>(same passphrase = same SHA256 key)
    A->>GPU: Launch SHA256 brute force<br/>(~10B attempts/sec on RTX 4090)
    GPU-->>A: Passphrase cracked in hours
    A->>A: Decrypt ALL matching records

SHA256’s speed turns a DB leak into a total compromise, not a partial one.

cryptio: the right primitives, the same simplicity

cryptio is a Go library I built to solve exactly these problems without having to rewrite cryptography from scratch. The API is deliberately minimal:

import "github.com/azrod/cryptio"

client, err := cryptio.New("my-passphrase", cryptio.SecurityStandard, cryptio.ProfileBalanced)
if err != nil {
    log.Fatal(err)
}
defer client.Wipe()

encrypted, err := client.Encrypt("sk_live_xxxxxxxxxxxx")
plaintext, err := client.Decrypt(encrypted)

Three lines to encrypt, three to decrypt. Under the hood, every problem identified above is addressed.

Argon2id as the KDF

Argon2id won the Password Hashing Competition in 2015. It was designed to be expensive in both time and memory, making it resistant to GPU and ASIC acceleration. With cryptio’s SecurityStandard level:

// Internal parameters for SecurityStandard:
// ArgonTime:    2 (iterations)
// ArgonMem:     64 * 1024  (64 MiB)
// ArgonThreads: 1
// SaltSize:     16 bytes
// KeySize:      32 bytes

On the same GPU that was computing 10 billion SHA256 hashes per second, Argon2id with these parameters drops to a few dozen attempts per second at best. The gap isn’t a factor of 2 or 10 — it’s several orders of magnitude. OWASP and NIST both recommend it for exactly this reason.

Random salt per encryption

Every call to Encrypt() generates a cryptographically random 16-byte salt via crypto/rand. That salt is encoded directly into the produced payload. Compared to the naive approach, the structural difference is significant:

graph LR
    n1["naive — nonce 12B"] --> n2[ciphertext]
    c1["cryptio — salt 16B"] --> c2["nonce 12B"] --> c3[ciphertext]
    style n1 fill:#3d0000,color:#ff9999,stroke:#ff0000
    style n2 fill:#3d0000,color:#ff9999,stroke:#ff0000
    style c1 fill:#003d00,color:#99ff99,stroke:#00ff00
    style c2 fill:#003d00,color:#99ff99,stroke:#00ff00
    style c3 fill:#003d00,color:#99ff99,stroke:#00ff00

Two calls with the same passphrase and the same plaintext produce two different ciphertexts. Duplicates become undetectable, rainbow tables become useless.

Memory cleanup

defer client.Wipe() zeroes out the passphrase byte slice byte by byte. The key derived by Argon2id is also zeroed via an internal defer after each operation. It’s not an absolute guarantee, but it’s a lot better than leaving strings floating in memory indefinitely.

Choosing your security level

cryptio exposes five preconfigured levels. The right choice depends on context and acceptable latency — these numbers were measured on an M1 Pro:

LevelTimeRAMUse case
UltraFast~30–48 ms~7–46 MBTests only, never in production
Standard~81–209 ms~64 MBAPIs, SaaS — recommended default
Medium~140–233 ms~128 MBEnterprise, GDPR/NIST compliance
High~388–485 ms~256 MBHealthcare, finance, critical data
Extreme>1.2 s~1 GBVaults, infrastructure secrets
Note

From SecurityStandard onwards, the library enforces a memory minimum — the Argon2 profiles only affect CPU and computation time, not RAM.

On top of the levels, five profiles let you trade RAM for CPU depending on your environment’s constraints:

// Server with plenty of RAM available
client, _ := cryptio.New(pass, cryptio.SecurityStandard, cryptio.ProfileRAMHeavy)

// Memory-constrained environment (Lambda, sidecar, etc.)
client, _ := cryptio.New(pass, cryptio.SecurityStandard, cryptio.ProfileCPUHeavy)

Real integration: encrypting database fields

Back to the original scenario. Here’s how to wire cryptio into the Account struct cleanly:

Attention

The passphrase should never live in source code or a committed .env file. Inject it via an environment variable backed by a secret manager (K8s Secrets, AWS Secrets Manager, HashiCorp Vault).

var cryptoClient *cryptio.Client

func init() {
    passphrase := os.Getenv("ENCRYPTION_KEY")
    if passphrase == "" {
        log.Fatal("ENCRYPTION_KEY is not set")
    }
    var err error
    cryptoClient, err = cryptio.New(passphrase, cryptio.SecurityStandard, cryptio.ProfileBalanced)
    if err != nil {
        log.Fatalf("cryptio init: %v", err)
    }
}

func (a *Account) Encrypt() error {
    var err error
    a.StripeAPIKey, err = cryptoClient.Encrypt(a.StripeAPIKey)
    if err != nil {
        return fmt.Errorf("encrypting StripeAPIKey: %w", err)
    }
    a.WebhookSecret, err = cryptoClient.Encrypt(a.WebhookSecret)
    if err != nil {
        return fmt.Errorf("encrypting WebhookSecret: %w", err)
    }
    return nil
}

func (a *Account) Decrypt() error {
    var err error
    a.StripeAPIKey, err = cryptoClient.Decrypt(a.StripeAPIKey)
    if err != nil {
        return fmt.Errorf("decrypting StripeAPIKey: %w", err)
    }
    a.WebhookSecret, err = cryptoClient.Decrypt(a.WebhookSecret)
    if err != nil {
        return fmt.Errorf("decrypting WebhookSecret: %w", err)
    }
    return nil
}

The client is initialized once at startup — there’s no way you’re re-deriving the Argon2id key on every request, that’s 100–200 ms per operation. Before writing to the database: account.Encrypt(). After reading: account.Decrypt(). The rest of your code only ever sees readable strings.

The limitations you should know about

Argon2 parameter versioning

The cryptio payload encodes the salt and the nonce, but not the Argon2 parameters (security level, profile). If you switch from SecurityStandard to SecurityHigh six months from now, you won’t be able to decrypt old records with the new client — the derivation parameters differ, so the produced key will differ too.

The fix: store the level and profile used for each record in your database, or plan a batch migration before changing parameters.

Symmetric encryption shifts the problem, it doesn’t solve it

Encrypting your database is a good thing. But the passphrase has to live somewhere. If it’s in your source code or a committed .env, everything falls apart. For critical workloads, the next step is envelope encryption: your passphrase itself is encrypted by a master key managed by a KMS (AWS KMS, GCP Cloud KMS, HashiCorp Vault). You never store the encryption key in plaintext anywhere.

sequenceDiagram
    participant App as Application
    participant SM as Secret Manager
    participant KMS as KMS
    participant DB as Database

    Note over App,KMS: Startup
    App->>SM: Fetch encrypted passphrase
    SM-->>App: Encrypted passphrase
    App->>KMS: Decrypt passphrase
    KMS-->>App: Plaintext passphrase (memory only)
    App->>App: cryptio.New(passphrase, ...)

    Note over App,DB: Runtime
    App->>App: account.Encrypt()
    App->>DB: INSERT — ciphertext stored
Conseil

For most SaaS applications, cryptio with a passphrase injected via secret manager is a perfectly reasonable level of protection. Envelope encryption on a KMS is the next layer — worth considering when you have real compliance requirements (SOC 2, PCI-DSS, HIPAA).

Application-level encryption vs. encryption at rest

cryptio encrypts individual values before they reach the database. It’s not a substitute for database-level encryption at rest (AWS RDS encryption, pgcrypto, etc.). The two are complementary:

graph LR
    App[Go Application] --> Cryptio["cryptio<br/>Argon2id + AES-GCM"]
    Cryptio -->|Ciphertext| DB[(Database)]
    DB --> AtRest["Encryption at rest<br/>AWS RDS · pgcrypto"]
    Threat1[SQL dump / stolen snapshot] -.->|blocked| AtRest
    Threat2[Compromised DB access] -.->|blocked| Cryptio

Encryption at rest protects physical files and snapshots. Application-level encryption protects data even when legitimate database access is compromised — a poorly secured ORM, an overly permissive read replica, a database console accessible without MFA.

← Back to articles