Package crypt (stdlib only). app.Crypt is built at boot from a key derived with HKDF from APP_KEY, plus keys derived from APP_PREVIOUS_KEYS. Outside production an app may boot without APP_KEY; app.Crypt is then nil and fails closed (crypt.ErrNoKey from Decrypt and SetCookie, a panic from Encrypt).
token := app.Crypt.EncryptString("4111 1111 1111 1111")
plain, err := app.Crypt.DecryptString(token) // crypt.ErrInvalid on any failure
// Additional data binds a token to its purpose; Open needs the same bytes.
token = app.Crypt.Seal(payload, []byte("invoice:42"))
payload, err = app.Crypt.Open(token, []byte("invoice:42"))- AES-256-GCM with a random 96-bit nonce per token; keys are 32 bytes (
crypt.New(current, previous...)). - A token is unpadded base64url of
version | key id | nonce | ciphertext | tag. The key id selects the key; the header is authenticated with the additional data. - Every failure (malformed, tampered, unknown key, other additional data) returns
crypt.ErrInvalid, without telling which.
Key rotation#
# .env
APP_KEY=base64:new…
APP_PREVIOUS_KEYS=base64:old…,base64:older…Previous keys only open or verify; everything new is sealed or signed with APP_KEY. A malformed entry is a boot error. They cover every feature keyed by APP_KEY:
app.Crypt: encrypted cookies,orm.Encryptedcolumns and queued mail payloads.- Cookie sessions, with the CSRF token and flash data they carry: re-sealed with the new key on the next request that reads them.
- The maintenance bypass cookie: re-issued under the new key.
- Signed URLs and social login state, when the app passes its previous keys (the
install:authscaffolds do):
signer := signed.New(key).WithPreviousKeys(app.DerivePreviousKeys("signed.urls", 32)...)
s := social.New(key, providers...).WithPreviousKeys(app.DerivePreviousKeys("social.state", 32)...)Remember-me, API and refresh tokens are stored as hashes, and JWT keys are configured separately, so they do not depend on APP_KEY.
if app.Crypt.NeedsRotation(token) { // sealed with a previous key
token, err = app.Crypt.Rotate(token, []byte("invoice:42"))
}To rotate:
- Locally, run
vukem key:rotate: it writes a newAPP_KEYto.envand moves the old one to the front ofAPP_PREVIOUS_KEYS(no key is printed). In production, generate a key (vukem key:generate --showor./app key:generate), setAPP_PREVIOUS_KEYS=<old key>andAPP_KEY=<new key>in the secret store, and deploy every instance with both. - Wait for what the old key signed to expire:
SESSION_LIFETIME, the TTL of signed links already sent (links without a TTL stop working), queued and failed mail jobs, 12 hours for maintenance bypass cookies. Re-save rows with encrypted columns (each save re-seals them) and re-seal other stored tokens withNeedsRotation/Rotate. - Remove the old key. Anything still sealed with it fails closed: the session is empty, signed links answer 403, mail jobs fail and encrypted columns return
crypt.ErrInvalid.
A previous key still verifies sessions and signed URLs, so whoever holds it can forge them during the window. After a leak, re-encrypt stored data in a short maintenance window that has the leaked key, then drop it at once instead of keeping it in APP_PREVIOUS_KEYS.
Encrypted cookies#
err := app.Crypt.SetCookie(w, r, http.Cookie{Name: "cart", Value: "3", Path: "/"})
cart, err := app.Crypt.Cookie(r, "cart") // http.ErrNoCookie or crypt.ErrInvalidThe cookie name is the additional data. SetCookie always sets HttpOnly, defaults SameSite to Lax, adds Secure when the request came over TLS (set it yourself behind a TLS-terminating proxy), refuses SameSite=None without Secure, and refuses values over 4 KB (crypt.ErrCookieTooLarge).
Encrypted columns#
orm.Encrypted[T] stores T as JSON encrypted with the connection's encrypter. app.DB() passes app.Crypt; elsewhere use database.Open(ctx, url, database.WithEncrypter(enc)).
type Patient struct {
orm.Model
Name string
Diagnosis orm.Encrypted[string] `json:"-"` // TEXT column
}
p := Patient{Name: "Ada", Diagnosis: orm.Encrypted[string]{V: "flu"}}
err := orm.Create(ctx, db, &p) // stores ciphertext
got, err := orm.QueryOf[Patient](db).Find(ctx, p.ID) // got.Diagnosis.V == "flu"- Sealed on
Create,UpdateandQuery.Update; opened by queries, eager loads andPluck. The additional data isorm:<table>.<column>, so a ciphertext copied to another column or table is rejected; renaming the table or column needs a re-encryption. - Fails closed: without an encrypter, writes and reads return
orm.ErrNoEncrypterand nothing is stored. Passing anEncryptedvalue todatabase/sqldirectly is an error, so plaintext never reaches the database by accident. - Saving a model re-encrypts every encrypted column with the current key.
- Not queryable: encryption is randomized, so
Where("diagnosis", …)cannot match. NULL reads as the zero value. - JSON encoding uses the plain value; tag the field
json:"-"to keep it out of API responses.
Two-factor authentication stores its secrets and recovery codes in encrypted columns.