Recipes

Data encryption

Some data — social security numbers, tax IDs, bank accounts, private notes — must be encrypted at rest. This recipe covers encrypting and decrypting individual model fields using AES-256-GCM with Go's standard library. No external dependencies.


The encryption helper

Create an encrypt package in your app with a Key type that handles encryption and decryption. Both methods use AES-256-GCM (authenticated encryption) and produce base64 strings for SQLite TEXT storage:

// encrypt/encrypt.go
package encrypt

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

// Key is a 32-byte AES-256 encryption key.
type Key [32]byte

// ParseKey decodes a 64-character hex string into a Key.
func ParseKey(s string) (Key, error) {
	var k Key
	b, err := hex.DecodeString(s)
	if err != nil {
		return k, errors.New("encryption key must be hex-encoded")
	}
	if len(b) != 32 {
		return k, errors.New("encryption key must be 32 bytes (64 hex characters)")
	}
	copy(k[:], b)
	return k, nil
}

// Encrypt encrypts plaintext with AES-256-GCM. Returns a base64 string
// for storage in a SQLite TEXT column. Each call produces unique output
// because a fresh random nonce is generated every time.
func (k Key) Encrypt(plaintext string) (string, error) {
	block, err := aes.NewCipher(k[:])
	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
	}
	sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
	return base64.StdEncoding.EncodeToString(sealed), nil
}

// Decrypt decodes base64 and decrypts with AES-256-GCM.
func (k Key) Decrypt(encoded string) (string, error) {
	data, err := base64.StdEncoding.DecodeString(encoded)
	if err != nil {
		return "", err
	}
	block, err := aes.NewCipher(k[:])
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	if len(data) < gcm.NonceSize() {
		return "", errors.New("ciphertext too short")
	}
	plaintext, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil)
	if err != nil {
		return "", errors.New("decryption failed: invalid key or corrupted data")
	}
	return string(plaintext), nil
}

The Key type enforces the correct 32-byte size at compile time. Encrypt prepends the random nonce to the ciphertext before base64 encoding, so Decrypt can extract it without external state.

AES-GCM

AES-GCM provides both confidentiality and integrity. If anyone tampers with the ciphertext, Decrypt returns an error instead of corrupted data. On modern CPUs with AES-NI instructions, encryption and decryption add negligible overhead.


Wiring the encryption key

Generate a key with OpenSSL:

openssl rand -hex 32
# e.g. 4f3c2b1a9e8d7f6054a3b2c1d0e9f8a74f3c2b1a9e8d7f6054a3b2c1d0e9f8a7

Set it as an environment variable:

# .env / Railway / Cloud Run
STANZA_ENCRYPTION_KEY=4f3c2b1a9e8d7f6054a3b2c1d0e9f8a74f3c2b1a9e8d7f6054a3b2c1d0e9f8a7

Wire it through the DI container in main.go:

func provideConfig() *config.Config {
	cfg := config.New(config.WithEnvPrefix("STANZA"))

	// ... existing defaults ...

	// Encryption — required in production, optional in development.
	cfg.SetDefault("encryption.key", "")

	return cfg
}

func provideEncryptionKey(cfg *config.Config) encrypt.Key {
	raw := cfg.Get("encryption.key")
	if raw == "" {
		// Generate a random key for development.
		// Data encrypted with this key is lost on restart.
		var k encrypt.Key
		_, _ = rand.Read(k[:])
		return k
	}
	key, err := encrypt.ParseKey(raw)
	if err != nil {
		panic("invalid STANZA_ENCRYPTION_KEY: " + err.Error())
	}
	return key
}

Inject the key into modules that need it:

func registerModules(router *http.Router, db *sqlite.DB, key encrypt.Key) {
	api := router.Group("/api")

	customers.Register(api.Group("/customers"), db, key)
	// ... other modules ...
}

Never log the key

The encryption key is the most sensitive secret in your application. Never log it, never include it in error messages, and never commit it to version control. Treat it like a database password — store it in your deployment platform's secret management.


Schema for encrypted columns

Encrypted fields are stored as regular TEXT columns. The encryption is handled in Go, not SQL:

migration.Register(1710900000, migration.Migration{
	Name: "create_customers",
	Up: `CREATE TABLE customers (
		id         INTEGER PRIMARY KEY,
		name       TEXT NOT NULL,
		email      TEXT NOT NULL UNIQUE,
		ssn        TEXT NOT NULL DEFAULT '',
		notes      TEXT NOT NULL DEFAULT '',
		created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
		updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
	)`,
})

The encrypted columns (ssn, notes) look like any other TEXT column. The base64 ciphertext is typically 40–60% larger than the plaintext, plus a fixed 28-byte overhead (12-byte nonce + 16-byte GCM authentication tag).


Encrypting on write

Encrypt sensitive fields before INSERT or UPDATE:

func createHandler(db *sqlite.DB, key encrypt.Key) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		var body struct {
			Name  string `json:"name"`
			Email string `json:"email"`
			SSN   string `json:"ssn"`
			Notes string `json:"notes"`
		}
		if !http.BindJSON(w, r, &body) {
			return
		}
		if err := validate.Fields(
			validate.Required("name", body.Name),
			validate.Email("email", body.Email),
		); err != nil {
			http.WriteValidationError(w, err)
			return
		}

		// Encrypt sensitive fields before storage.
		encSSN, err := key.Encrypt(body.SSN)
		if err != nil {
			http.Error(w, http.StatusInternalServerError, "encryption failed")
			return
		}
		encNotes, err := key.Encrypt(body.Notes)
		if err != nil {
			http.Error(w, http.StatusInternalServerError, "encryption failed")
			return
		}

		sb := sqlite.InsertInto("customers").
			Set("name", body.Name).
			Set("email", body.Email).
			Set("ssn", encSSN).
			Set("notes", encNotes)
		result, err := db.Exec(sb.Build())
		if err != nil {
			http.Error(w, http.StatusInternalServerError, "failed to create customer")
			return
		}

		id, _ := result.LastInsertId()
		http.WriteJSON(w, http.StatusCreated, map[string]int64{"id": id})
	}
}

Each call to Encrypt generates a fresh random nonce, so encrypting the same plaintext twice produces different ciphertext. This prevents an attacker from detecting duplicate values by comparing stored data.


Decrypting on read

Decrypt after reading from the database. Check for empty strings before decrypting — rows with the default empty value should return empty, not a decryption error:

func getHandler(db *sqlite.DB, key encrypt.Key) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		id, ok := http.PathParamInt64(w, r, "id")
		if !ok {
			return
		}

		sb := sqlite.Select("id", "name", "email", "ssn", "notes", "created_at").
			From("customers").
			Where("id = ?", id)
		row := db.QueryRow(sb.Build())

		var c struct {
			ID        int64  `json:"id"`
			Name      string `json:"name"`
			Email     string `json:"email"`
			SSN       string `json:"ssn"`
			Notes     string `json:"notes"`
			CreatedAt string `json:"created_at"`
		}
		var encSSN, encNotes string
		if err := row.Scan(&c.ID, &c.Name, &c.Email, &encSSN, &encNotes, &c.CreatedAt); err != nil {
			http.Error(w, http.StatusNotFound, "customer not found")
			return
		}

		// Decrypt sensitive fields.
		if encSSN != "" {
			plain, err := key.Decrypt(encSSN)
			if err != nil {
				http.Error(w, http.StatusInternalServerError, "decryption failed")
				return
			}
			c.SSN = plain
		}
		if encNotes != "" {
			plain, err := key.Decrypt(encNotes)
			if err != nil {
				http.Error(w, http.StatusInternalServerError, "decryption failed")
				return
			}
			c.Notes = plain
		}

		http.WriteJSON(w, http.StatusOK, c)
	}
}

Skipping encryption in list endpoints

List endpoints typically don't need sensitive fields. Omit encrypted columns from the SELECT to avoid unnecessary decryption:

func listHandler(db *sqlite.DB) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		page, perPage := http.ParsePagination(r)

		// Only select non-sensitive columns — no ssn, no notes.
		sb := sqlite.Select("id", "name", "email", "created_at").
			From("customers").
			OrderBy("created_at DESC").
			Limit(perPage).
			Offset((page - 1) * perPage)
		rows, err := db.Query(sb.Build())
		if err != nil {
			http.Error(w, http.StatusInternalServerError, "query failed")
			return
		}
		defer rows.Close()

		type item struct {
			ID        int64  `json:"id"`
			Name      string `json:"name"`
			Email     string `json:"email"`
			CreatedAt string `json:"created_at"`
		}
		var items []item
		for rows.Next() {
			var c item
			if err := rows.Scan(&c.ID, &c.Name, &c.Email, &c.CreatedAt); err != nil {
				http.Error(w, http.StatusInternalServerError, "scan failed")
				return
			}
			items = append(items, c)
		}

		total, _ := db.Count(sqlite.CountFrom("customers"))
		http.WriteJSON(w, http.StatusOK, http.PaginatedResponse(items, total, page, perPage))
	}
}

The detail endpoint decrypts on demand. The list endpoint stays fast by skipping encryption entirely.


Searching encrypted data

You cannot search or filter encrypted columns with SQL. WHERE ssn = ? will not match because the same plaintext produces different ciphertext each time.

For exact-match lookups, store a keyed HMAC hash alongside the encrypted value:

// encrypt/encrypt.go

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
)

// Hash creates a deterministic HMAC-SHA256 for exact-match lookups.
// The hash is not reversible — it cannot recover the original value.
func (k Key) Hash(value string) string {
	mac := hmac.New(sha256.New, k[:])
	mac.Write([]byte(value))
	return hex.EncodeToString(mac.Sum(nil))
}

Add an indexed hash column to the schema:

ALTER TABLE customers ADD COLUMN ssn_hash TEXT NOT NULL DEFAULT '';
CREATE INDEX idx_customers_ssn_hash ON customers(ssn_hash);

Compute the hash on write alongside encryption:

encSSN, err := key.Encrypt(body.SSN)
if err != nil {
	http.Error(w, http.StatusInternalServerError, "encryption failed")
	return
}
ssnHash := key.Hash(body.SSN)

sb := sqlite.InsertInto("customers").
	Set("name", body.Name).
	Set("email", body.Email).
	Set("ssn", encSSN).
	Set("ssn_hash", ssnHash).
	Set("notes", encNotes)

Query by hash for lookups:

func findBySSN(db *sqlite.DB, key encrypt.Key, ssn string) {
	hash := key.Hash(ssn)
	sb := sqlite.Select("id", "name", "email", "ssn", "created_at").
		From("customers").
		Where("ssn_hash = ?", hash)
	// ...
}

The hash is deterministic (same input always produces the same output) but not reversible. An attacker with database access cannot recover the original SSN from the hash.

Partial search

HMAC hashes only support exact matches. If you need to search by a partial value (like the last 4 digits of an SSN), store that specific searchable portion as a separate plaintext column. Only do this with the user's informed consent and a clear understanding of the privacy tradeoff.


Key rotation

When the encryption key is compromised or policy requires rotation, re-encrypt all data with a new key. Run this as a one-time operation:

func rotateEncryptionKey(db *sqlite.DB, oldKey, newKey encrypt.Key) error {
	sb := sqlite.Select("id", "ssn", "notes").
		From("customers").
		Where("ssn != '' OR notes != ''")
	rows, err := db.Query(sb.Build())
	if err != nil {
		return err
	}
	defer rows.Close()

	type record struct {
		ID    int64
		SSN   string
		Notes string
	}
	var records []record
	for rows.Next() {
		var r record
		if err := rows.Scan(&r.ID, &r.SSN, &r.Notes); err != nil {
			return err
		}
		records = append(records, r)
	}
	rows.Close()

	for _, r := range records {
		update := sqlite.Update("customers").Where("id = ?", r.ID)

		if r.SSN != "" {
			plain, err := oldKey.Decrypt(r.SSN)
			if err != nil {
				return fmt.Errorf("decrypt ssn for id %d: %w", r.ID, err)
			}
			enc, err := newKey.Encrypt(plain)
			if err != nil {
				return fmt.Errorf("re-encrypt ssn for id %d: %w", r.ID, err)
			}
			update = update.Set("ssn", enc)

			// Update the HMAC hash if using exact-match lookups.
			update = update.Set("ssn_hash", newKey.Hash(plain))
		}
		if r.Notes != "" {
			plain, err := oldKey.Decrypt(r.Notes)
			if err != nil {
				return fmt.Errorf("decrypt notes for id %d: %w", r.ID, err)
			}
			enc, err := newKey.Encrypt(plain)
			if err != nil {
				return fmt.Errorf("re-encrypt notes for id %d: %w", r.ID, err)
			}
			update = update.Set("notes", enc)
		}

		if _, err := db.Exec(update.Build()); err != nil {
			return fmt.Errorf("update id %d: %w", r.ID, err)
		}
	}
	return nil
}

After re-encryption completes, update STANZA_ENCRYPTION_KEY in your environment and restart the application.

Backup first

Always back up the database before key rotation. If the process is interrupted, some rows will be encrypted with the old key and others with the new key. The backup lets you start over cleanly.


Tips

  • Encrypt selectively. Only encrypt fields that genuinely need protection at rest — SSNs, tax IDs, bank accounts, medical records, private notes. Encrypting everything adds complexity without proportional security benefit.
  • Hash passwords, don't encrypt them. Passwords should be one-way hashed with auth.HashPassword, not encrypted. You never need to recover a plaintext password.
  • Empty string means no data. Always check for empty strings before decrypting. Rows created with DEFAULT '' should return empty, not a decryption error.
  • Each encrypt call produces unique output. AES-GCM uses a random nonce, so encrypting "123-45-6789" twice produces different ciphertext. This is a security feature. Use HMAC hashes for exact-match lookups.
  • Skip sensitive fields in list responses. List endpoints should omit encrypted columns from the SELECT entirely. Only decrypt on detail views where the user has been authorized.
  • The key is the single point of failure. Lose the key and the encrypted data is unrecoverable. Store it in your deployment platform's secret management (Railway environment variables, Cloud Run secrets) and keep a secure offline backup.
  • Don't encrypt data you need to sort or aggregate. SQL operations like ORDER BY, GROUP BY, SUM, and range queries (WHERE amount > 100) cannot work on encrypted columns. Only encrypt fields that are written and read back as-is.
Previous
Database transactions