Vault Error: 'invalid ciphertext: unable to decrypt' Transit Key Version and Rewrap Issues
Fix Vault Transit decrypt failures: understand the vault:v1: prefix, min_decryption_version, key rotation and rewrap, derived-key context mismatches, and base64 plaintext rules.
- #vault
- #secrets
- #security-hardening
- #troubleshooting
- #errors
Stuck on this HashiCorp Vault error? Get the free incident triage checklist
A one-page PDF — the exact steps to isolate, fix, and verify a production error like this one. No spam, unsubscribe anytime.
Exact Error Message
Error making API request.
URL: PUT https://vault.example.com:8200/v1/transit/decrypt/app-key
Code: 400. Errors:
* invalid ciphertext: unable to decrypt
When the ciphertext references a key version you have trimmed away, you may instead see:
Error making API request.
URL: PUT https://vault.example.com:8200/v1/transit/decrypt/app-key
Code: 400. Errors:
* ciphertext or signature version is disallowed by policy (too old)
What It Means
Transit ciphertext is self-describing: vault:v2:8SDd3WHDOjf7mq69... means “this blob was encrypted with version 2 of the named key”. Vault reads that prefix, looks up that exact key version in the key’s policy, and decrypts. The version is not metadata you can rewrite — the bytes are bound to it. That is why moving ciphertext between key names, mounts, or clusters fails even when the key material looks equivalent to you: app-key on cluster A and app-key on cluster B are unrelated keys.
The two error shapes distinguish two different failures. invalid ciphertext: unable to decrypt means Vault found the key version but the authenticated decryption failed — wrong key, corrupted or truncated blob, or a derived key with the wrong context. disallowed by policy (too old) means the version exists in history but min_decryption_version has been raised above it, deliberately making that ciphertext undecryptable until you lower the floor or rewrap. Rotation alone never breaks old ciphertext; raising min_decryption_version is what breaks it, which is why rotation and version trimming must be separated by a completed rewrap.
Common Causes
- The ciphertext was produced by a different key name, a different transit mount, or a different Vault cluster.
min_decryption_versionwas raised past the version embedded in the ciphertext before all data was rewrapped.- The key uses key derivation and the
contextsupplied at decrypt does not byte-for-byte match the one used at encrypt. - The ciphertext was stored in a column that truncated it, or was re-base64’d, mangling the blob.
- Plaintext was sent to
transit/encryptunencoded — Transit requires base64 input, so what round-trips is not what you expected. - The token’s policy grants
transit/encrypt/*but nottransit/decrypt/*, producing a permission error that gets misread as a cipher failure. - Convergent encryption was enabled or disabled on the key after some data was already written.
Diagnostic Commands
Inspect the key: its type, current version, and the decryption/encryption floors that govern what is usable:
vault read transit/keys/app-key
vault read -format=json transit/keys/app-key \
| jq '{latest_version, min_decryption_version, min_encryption_version, type, derived, convergent_encryption}'
Read the version out of the ciphertext itself — this single field tells you whether you have a version problem or a key problem:
echo "vault:v2:8SDd3WHDOjf7mq69..." | cut -d: -f2
Confirm the mount and name are what the producing application used. A transit engine mounted at transit/ and one at kv-transit/ are entirely separate:
vault secrets list | grep -i transit
Prove the round trip works right now with a fresh value. If this succeeds, the key is healthy and the problem is specific to the stored ciphertext:
CT=$(vault write -field=ciphertext transit/encrypt/app-key \
plaintext="$(echo -n 'probe' | base64)")
vault write -field=plaintext transit/decrypt/app-key ciphertext="$CT" | base64 -d
Check that your token actually has decrypt capability, since a policy gap returns permission denied rather than a cipher error:
vault token capabilities "$(vault print token)" transit/decrypt/app-key
For derived keys, verify the exact context bytes. Context must be base64 and must match precisely — a trailing newline from echo without -n is a classic silent breaker:
echo -n 'tenant-42' | base64
Step-by-Step Resolution
- Determine which version the failing ciphertext needs and compare it to the key’s floor. If the version is below
min_decryption_version, that is your answer:
vault read -field=min_decryption_version transit/keys/app-key
- If you raised the floor prematurely, lower it back so the old ciphertext becomes decryptable again, then rewrap before raising it a second time:
vault write transit/keys/app-key/config min_decryption_version=1
- Rewrap the affected ciphertext. Rewrap re-encrypts to the latest key version without ever exposing plaintext — the caller needs no decrypt permission on the data, only rewrap capability:
vault write -field=ciphertext transit/rewrap/app-key \
ciphertext="vault:v1:8SDd3WHDOjf7mq69..."
For a batch, use the batch input form rather than looping one request per row:
{
"batch_input": [
{ "ciphertext": "vault:v1:abc..." },
{ "ciphertext": "vault:v1:def..." }
]
}
vault write transit/rewrap/app-key @batch.json
- For derived keys, supply the identical context on both operations. The context participates in key derivation, so a mismatch is cryptographically indistinguishable from a wrong key:
CTX=$(echo -n 'tenant-42' | base64)
CT=$(vault write -field=ciphertext transit/encrypt/app-key \
plaintext="$(echo -n 'secret' | base64)" context="$CTX")
vault write -field=plaintext transit/decrypt/app-key \
ciphertext="$CT" context="$CTX" | base64 -d
- Grant the policy the operations your service actually performs. Encrypt-only services should not hold decrypt; rewrap workers need rewrap but not decrypt:
path "transit/encrypt/app-key" {
capabilities = ["update"]
}
path "transit/decrypt/app-key" {
capabilities = ["update"]
}
path "transit/rewrap/app-key" {
capabilities = ["update"]
}
- Once every stored ciphertext has been rewrapped, rotate and then raise the floors together. Raising
min_encryption_versionforces new writes onto the new version; raisingmin_decryption_versionretires the old one:
vault write -f transit/keys/app-key/rotate
vault write transit/keys/app-key/config \
min_decryption_version=2 \
min_encryption_version=2
If your application uses envelope encryption, remember that transit/datakey/plaintext/app-key returns both a plaintext key and a vault:vN: wrapped copy. Only the wrapped copy should be stored, and it is subject to exactly the same version rules as any other Transit ciphertext.
Prevention
- Store the full
vault:vN:string verbatim, in a column wide enough for it, and never re-encode it. - Rewrap fully before raising
min_decryption_version— treat the floor as the last step of a migration, never the first. - Pin the key name and mount path in configuration rather than deriving them per-environment, so cross-cluster ciphertext cannot be mixed.
- Base64-encode plaintext at a single choke point in your client library so no caller can send raw bytes by accident.
- Decide on derived and convergent encryption at key-creation time; neither can be toggled sensibly after data exists.
- Track a rewrap backlog metric so you know when it is actually safe to retire an old key version.
Related Errors
permission deniedontransit/decrypt/<key>— a policy gap, not a cipher failure; check withvault token capabilities.no handler for route 'transit/decrypt/...'— wrong mount path or wrong namespace. See Vault error: namespace not found.wrapping token is not valid or does not exist— response wrapping, unrelated to Transit. See Vault error: wrapping token expired.Vault is sealed— the barrier is closed so no Transit operation can run. See Vault error: auto-unseal KMS access denied.
Frequently Asked Questions
Does rotating a Transit key break existing ciphertext? No. Rotation adds a new version and makes it the encryption default; all prior versions remain available for decryption. Ciphertext only becomes unusable when min_decryption_version is raised above its embedded version.
Can I rewrap without granting decrypt permission? Yes, and you should. The transit/rewrap/<key> endpoint performs decrypt-then-encrypt entirely inside Vault and returns only new ciphertext. A migration job therefore never needs the ability to read your plaintext.
Why does the same plaintext produce different ciphertext each time? That is expected for a standard key — a random nonce is used per operation. If you need deterministic output for equality lookups, the key must be created with convergent encryption and derivation enabled, which is a security trade-off you should make deliberately.
How do I decrypt data from a decommissioned cluster? You cannot, unless you restore that cluster’s Transit key material. Key names are not portable; a key called app-key elsewhere is a different key. Plan a rewrap-and-migrate before decommissioning. More Transit and key-management guides are in the Vault guides.
Fixed it? Get 500 HashiCorp Vault & DevOps AI prompts — free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.
Did this fix your issue?
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.