Encrypt Short Values with Vault Transit: Keys Stay in Vault
Vault Transit encrypts small application values without exposing keys. See corrected CLI paths, a Go example using Logical().Write, ACL rules, and operational limits.
14 May 2026, 18:38 UTC

When a service needs to store a short token or a configuration value, the cryptographic decision is only half the problem. The other half is key management: rotation, storage, access control, and audit. HashiCorp Vault's Transit secrets engine addresses that second half by exposing encryption as an HTTP API. Your application sends plaintext to Vault and receives ciphertext; the symmetric key never leaves the Vault server.
Transit as a remote cryptographic service
Transit mounts at a path (commonly transit/) and stores keys inside Vault's encrypted storage backend. Keys are non-exportable by design, so a compromised application host cannot exfiltrate key material. Each ciphertext embeds the key version that produced it, for example vault:v1:.... That versioning lets you rotate keys without immediately re-encrypting existing data: older ciphertext remains decryptable until you explicitly rewrap it.
Mount the engine and grant narrow permissions
Transit access is controlled by path-based ACL policies. There is no single transit capability. To allow a service to encrypt and decrypt under a key named app-secrets, grant update on the specific endpoint paths.
# Enable the engine (run with a token that has sudo or sufficient permissions on the sys/mounts path)
vault secrets enable transit
# Create a key named app-secrets
vault write -f transit/keys/app-secrets
A minimal policy for the application token looks like this:
path "transit/encrypt/app-secrets" {
capabilities = ["update"]
}
path "transit/decrypt/app-secrets" {
capabilities = ["update"]
}
Granting read on transit/keys/app-secrets only exposes metadata such as the current key version and creation time; it does not permit encryption or decryption. Also note that Vault does not record operations by default—you must explicitly enable an audit device, such as a file or syslog audit device, to capture transit/encrypt and transit/decrypt events.
Worked example: CLI and Go client
Transit expects plaintext to be base64-encoded. The CLI does not encode file contents for you when you use plaintext=@file; it passes the raw bytes. Encode the value first.
# Linux (GNU coreutils)
PLAINTEXT=$(base64 -w0 sample.txt)
vault write transit/encrypt/app-secrets plaintext="$PLAINTEXT"
# macOS (BSD base64 lacks -w0)
PLAINTEXT=$(base64 -i sample.txt | tr -d '\n')
vault write transit/encrypt/app-secrets plaintext="$PLAINTEXT"
The response includes a ciphertext field. Because Transit uses randomized nonces, the ciphertext changes on every call even when the plaintext is identical. Do not verify correctness by comparing ciphertext strings; decrypt the value and compare the recovered plaintext.
# Decrypt
vault write transit/decrypt/app-secrets ciphertext="vault:v1:..."
# Rotate the key so future encryptions use a new version
vault write -f transit/keys/app-secrets/rotate
After rotation, existing v1 ciphertext still decrypts, while new encrypt calls return vault:v2:.... The correct rotation path is transit/keys/<name>/rotate; there is no top-level transit/rotate-key endpoint.
The official github.com/hashicorp/vault/api package does not expose a dedicated Transit helper. Use the generic logical client instead.
package main
import (
"context"
"encoding/base64"
"fmt"
"log"
"github.com/hashicorp/vault/api"
)
func main() {
// Assumes VAULT_ADDR and VAULT_TOKEN are set, or equivalent config.
client, err := api.NewClient(api.DefaultConfig())
if err != nil {
log.Fatalf("client init: %v", err)
}
keyName := "app-secrets"
encoded := base64.StdEncoding.EncodeToString([]byte("user-session-token"))
// Encrypt
encResp, err := client.Logical().Write(
"transit/encrypt/"+keyName,
map[string]interface{}{"plaintext": encoded},
)
if err != nil {
log.Fatalf("encrypt failed: %v", err)
}
ciphertext := encResp.Data["ciphertext"].(string)
fmt.Printf("ciphertext: %s\n", ciphertext)
// Decrypt
decResp, err := client.Logical().Write(
"transit/decrypt/"+keyName,
map[string]interface{}{"ciphertext": ciphertext},
)
if err != nil {
log.Fatalf("decrypt failed: %v", err)
}
recovered, _ := base64.StdEncoding.DecodeString(decResp.Data["plaintext"].(string))
fmt.Printf("recovered: %s\n", string(recovered))
}
If the token lacks the required update capability, the API returns HTTP 403. Run this against a development Vault cluster before wiring it into production services.
Limits and operational trade-offs
- Data size: Transit is optimized for short values—tokens, passwords, or small metadata blobs. Encrypting large files adds unnecessary network overhead and may approach request-size limits.
- Performance: Each encrypt or decrypt call is a network round-trip and CPU work on the Vault node. For high-volume workloads, batch multiple items in a single request using the
batch_inputparameter on the encrypt or decrypt endpoint. - Recovery: Because keys are non-exportable, losing the Vault storage backend (or HSM, if used) makes the data unrecoverable without a recent Vault snapshot.
- Namespaces: Transit keys live at a mount path. Vault Enterprise namespaces are a separate isolation feature; do not conflate the two.
- Version sensitivity: CLI paths and API details evolve across Vault releases. Verify the exact endpoint syntax against your deployed version before production use.
Where to start
- Enable Transit in a non-production cluster and create a test key.
- Apply a narrow policy that grants
updateonly on the encrypt and decrypt paths your service needs. - Run the CLI round-trip above, then decrypt the result to confirm the plaintext matches.
- Add the Go wrapper to a single service and verify end-to-end behavior.
- Enable an audit device and confirm that
transit/encryptevents appear in the log before promoting to production.
Transit does not make cryptography disappear, but it moves key lifecycle, rotation, and audit into a single control plane. For short application values, that trade is usually worth the operational dependency on Vault.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.