Application encryption is a recurring source of bugs: developers pick weak algorithms, hardcode IVs, forget to authenticate, store keys next to data. Vault's Transit secrets engine solves this by making Vault itself the cryptographic operation provider. Apps never see keys; they call Vault to encrypt and decrypt.
The Core Model
App → "encrypt this plaintext with key 'orders-pii'" → Vault
Vault → ciphertext
App → stores ciphertext in DB
…
App → "decrypt this ciphertext with key 'orders-pii'" → Vault
Vault → plaintext
The encryption key lives inside Vault, protected by Vault's barrier. The app holds only ciphertext.
Setting Up Transit
vault secrets enable transit
# Create a named key
vault write -f transit/keys/orders-pii
# Inspect
vault read transit/keys/orders-pii
The key has a type (default: aes256-gcm96), a list of versions, and configuration: deletion_allowed, exportable, allow_plaintext_backup, etc.
Encrypt and Decrypt
vault write transit/encrypt/orders-pii \
plaintext=$(echo -n "Alice's SSN: 123-45-6789" | base64)
# Returns: ciphertext=vault:v1:abc...
vault write transit/decrypt/orders-pii \
ciphertext=vault:v1:abc...
# Returns: plaintext (base64-encoded)
The vault:v1: prefix encodes the key version. When you rotate the key, new encrypts use v2:; existing v1: ciphertexts continue to decrypt because Vault retains old versions.
Key Rotation
vault write -f transit/keys/orders-pii/rotate
vault read transit/keys/orders-pii
# Now shows latest_version=2
All new encrypts use v2. Old v1 ciphertexts still decrypt. To force migration of stored ciphertexts to the new key version, use rewrap:
vault write transit/rewrap/orders-pii ciphertext=vault:v1:abc...
# Returns: ciphertext=vault:v2:xyz... (same plaintext, new key version)
Rewrap doesn't expose the plaintext to the caller — Vault rewraps internally. You can run a one-off job that scans your DB, calls rewrap on every ciphertext, and migrates the column.
Min Decryption Version
Once you've rewrapped all data to the new version, you can retire the old version by setting min_decryption_version:
vault write transit/keys/orders-pii/config min_decryption_version=2
Vault will refuse to decrypt v1 ciphertexts. Combined with min_encryption_version, this enforces a key-rotation lifecycle.
Convergent Encryption
By default, Transit uses a random nonce, so encrypting the same plaintext twice produces different ciphertexts (secure, but not searchable). Convergent encryption derives the nonce from the plaintext and a context, producing deterministic ciphertext for the same input:
vault write -f transit/keys/email-pii \
type=aes256-gcm96 \
convergent_encryption=true \
derived=true
Use case: encrypting an email address column where you need to look up by encrypted value. The cost: leaking equality patterns. Use sparingly and with awareness of the tradeoff.
Data Keys
For very large data (multi-megabyte files), it's inefficient to ship every byte to Vault. The datakey pattern:
- App asks Vault: "Generate a data key under 'orders-pii'"
- Vault returns: plaintext AES key + ciphertext (the plaintext key encrypted by the master)
- App encrypts the large data locally with the plaintext key, in memory
- App stores the ciphertext data + ciphertext key alongside
- App discards the plaintext key
- To decrypt: app sends ciphertext key to Vault, gets plaintext key, decrypts the data locally
This is exactly how AWS KMS envelope encryption works. Vault Transit can serve the same role.
Sign, Verify, HMAC
Transit isn't just symmetric encryption. You can also:
- Generate signing keys (RSA, ECDSA, Ed25519) and sign payloads
- Verify signatures
- Generate HMAC of an input (useful for tokenisation, deterministic hashing)
- Encrypt with asymmetric keys (RSA-OAEP)
vault write -f transit/keys/jwt-signer type=rsa-2048
vault write transit/sign/jwt-signer input=$(echo -n '{"sub":"alice"}' | base64)
This is how some teams implement JWT signing without ever giving the app the private key.
Performance and Caching
Transit operations are CPU-bound on the Vault server. Benchmarks: a 4-vCPU Vault instance handles thousands of encrypts/decrypts per second. For higher throughput, scale Vault horizontally and use a load balancer.
Apps should cache decrypted plaintexts in memory (with care) when the same value is read repeatedly — never bypass Vault to "save calls", but don't decrypt the same row twice per request either.
Policies for Transit
path "transit/encrypt/orders-pii" { capabilities = ["update"] }
path "transit/decrypt/orders-pii" { capabilities = ["update"] }
Note: encrypt/decrypt are update in Vault's model (the request body changes), not read. You can grant a service the right to encrypt only (write-only data flow) by omitting the decrypt path — useful for log shippers that need to encrypt PII but should never read it back.
When to Use Transit
| Use case | Use Transit? |
|---|---|
| Encrypt a few sensitive DB columns (PII, payment data) | Yes — perfect fit |
| File-level disk encryption | Use OS LUKS/dm-crypt; Transit is overkill |
| Sign JWTs without storing private keys in apps | Yes |
| Replace AWS KMS for envelope encryption | Yes — and you become multi-cloud |
| Encrypt very small request payloads | Yes — minimal overhead |
| Bulk encryption of TBs of data | Use datakey pattern — don't ship the bytes |
Transit lets you treat encryption as a service, not as a per-app implementation problem — and centralised key management is the single biggest improvement most apps can make to their encryption story.