← Back to list

Securing Your Secrets in Go: From DIY Disaster to Production-Ready

You’re building an API that stores OAuth tokens, third-party API keys, or sensitive user data. You need to encrypt them before persisting…

Mickael Stanislas · 2026-01-09 21:43 · 0 claps · 3.6 min read
#golang #cryptography #argon2 #security #passwords
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3 🔒 · Cybersecurity 🛠️ · Crafts & DIY

Securing Your Secrets in Go: From DIY Disaster to Production-Ready

You’re building an API that stores OAuth tokens, third-party API keys, or sensitive user data. You need to encrypt them before persisting to your database. How do you do it securely?

The real-world scenario

Imagine a SaaS application that syncs data with your customers’ Stripe APIs. For each customer account, you store:

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

The problem: If your database leaks (SQL dump, unencrypted backup, breach), every customer’s Stripe keys are exposed. Game over.

The solution: Encrypt these fields before insertion. But how?

First attempt: the “homemade” solution

A common quick‑start shows an AES‑GCM example

package main

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

// Encrypt encrypts plaintext with AES-GCM using a passphrase
func Encrypt(plaintext, passphrase string) (string, error) {
    // Derive key from passphrase with SHA256
    key := sha256.Sum256([]byte(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 base64.StdEncoding.EncodeToString(ciphertext), nil
}

// Decrypt decrypts encrypted text
func Decrypt(ciphertext, passphrase string) (string, error) {
    key := sha256.Sum256([]byte(passphrase))

    data, err := base64.StdEncoding.DecodeString(ciphertext)
    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()
    if len(data) < nonceSize {
        return "", errors.New("ciphertext too short")
    }

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

    return string(plaintext), nil
}

func main() {
    secret := "sk_live_51ABCxyz..."
    passphrase := "my-super-password"

    encrypted, _ := Encrypt(secret, passphrase)
    println("Encrypted:", encrypted)

    decrypted, _ := Decrypt(encrypted, passphrase)
    println("Decrypted:", decrypted)
}

This code works… but it’s dangerous ⚠️

Problem #1: SHA256 is NOT a KDF

key := sha256.Sum256([]byte(passphrase))

SHA256 is a fast hash function. That’s its job. But for deriving encryption keys, it’s catastrophic:

  • An attacker can test billions of passphrases per second on a GPU.
  • Even with a “correct” password (P@ssw0rd123), it falls in hours.
  • Rainbow tables and dictionary attacks are trivial.

Problem #2: No salt

Two users with the same passphrase will produce the same AES key. An attacker can:

  • Detect duplicates in your database.
  • Crack one key and decrypt all identical records.

Problem #3: Memory management

plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
return string(plaintext), nil

Passphrases and derived keys remain in memory. A RAM dump or crash dump can expose them.

Problem #4: Upgradeability

If you want to strengthen security in 6 months (use Argon2, increase iterations), all your old ciphertexts become incompatible. No versioning, no metadata.

The right approach: cryptio

Here’s the same code, rewritten with cryptio:

package main

import (
    "fmt"
    "github.com/azrod/cryptio"
)

func main() {
    // Create a client with appropriate security level
    client, err := cryptio.New(
        "my-super-password",
        cryptio.SecurityStandard,  // OWASP-compliant
        cryptio.ProfileBalanced,    // RAM/CPU balance
    )
    if err != nil {
        panic(err)
    }
    defer client.Wipe() // Clean passphrase from memory

    // Encrypt
    secret := "sk_live_51ABCxyz..."
    encrypted, err := client.Encrypt(secret)
    if err != nil {
        panic(err)
    }
    fmt.Println("Encrypted:", encrypted)

    // Decrypt
    decrypted, err := client.Decrypt(encrypted)
    if err != nil {
        panic(err)
    }
    fmt.Println("Decrypted:", decrypted)
}

That’s it. 3 lines to encrypt, 3 lines to decrypt.

Why cryptio is secure

1. Argon2id: the modern KDF reference

Instead of SHA256, cryptio uses Argon2id (winner of the Password Hashing Competition):

  • GPU/ASIC resistant: uses lots of memory (~64 MB in Standard mode).
  • Slow by design: an attacker can only test ~10–100 passphrases/second (vs billions with SHA256).
  • Recommended by OWASP and NIST.
// cryptio automatically generates:
salt := random(16 bytes)  // Unique per message
key := argon2.IDKey(
    passphrase,
    salt,
    time: 2,        // Iterations
    memory: 64*1024, // 64 MB
    threads: 1,
)

2. Random salt for each encryption

Every call to Encrypt() generates a new cryptographically secure salt. Same message + same passphrase = **different*** ciphertext every time.

// Format: salt (16 bytes) || nonce (12 bytes) || ciphertext
encrypted1, _ := client.Encrypt("secret")
encrypted2, _ := client.Encrypt("secret")
// encrypted1 ≠ encrypted2 (but both decrypt to "secret")

3. Memory cleanup

defer client.Wipe()

Erases the passphrase from memory at the end. Derived keys are also cleaned after each operation (explicit zeroing).

4. Adaptive security levels

You can choose the level based on your context:

  • SecurityUltraFast (Use Case: Tests/IoT, RAM: ~16 MB, Time: ~30 ms)
  • SecurityStandard (Use Case: APIs/SaaS, RAM: ~64 MB, Time: ~80 ms)
  • SecurityMedium (Use Case: Enterprise/compliance, RAM: ~128 MB, Time: ~140 ms)
  • SecurityHigh (Use Case: Finance/healthcare, RAM: ~256 MB, Time: ~390 ms)
  • SecurityExtreme (Use Case: Vault/ultra-sensitive, RAM: ~1 GB, Time: >1.2 s)

For a typical web API, SecurityStandard is perfect. For storing infrastructure secrets, use SecurityHigh.

5. Argon2 profiles to adjust RAM vs CPU

// Environment with lots of RAM?
client, _ := cryptio.New(pass, cryptio.SecurityStandard, cryptio.ProfileRAMHeavy)

// RAM-constrained environment?
client, _ := cryptio.New(pass, cryptio.SecurityStandard, cryptio.ProfileCPUHeavy)

Profiles let you trade memory for CPU without compromising security.

Conclusion

Encrypting secrets is not trivial. “Homemade” solutions are riddled with cryptographic traps that seem to work… until the day an attacker breaks everything in a few hours.

cryptio gives you:

  • A simple API (3 lines of code).
  • Modern, proven primitives (Argon2id + AES-GCM).
  • Clear, documented security levels.
  • Minimal dependencies (only golang.org/x/crypto).

To install:

go get github.com/azrod/cryptio

Full documentation: github.com/azrod/cryptio

Security note: This article presents general best practices. Always have your cryptographic choices audited by a security team before production deployment.


메타데이터
post_id
3804eaf5e35a
slug
securing-your-secrets-in-go-from-diy-disaster-to-production-ready-3804eaf5e35a
url
https://medium.com/@azrod/securing-your-secrets-in-go-from-diy-disaster-to-production-ready-3804eaf5e35a
canonical_url
https://medium.com/@azrod/securing-your-secrets-in-go-from-diy-disaster-to-production-ready-3804eaf5e35a
author_url
https://medium.com/@azrod
status
ok
fetched_at
2026-07-13 13:01:55