Skip to content
🎉 Launch sale:50% off everything over $22 — automatically applied at checkout· ends Aug 2Shop the sale →
DevOps AI ToolKit
Newsletter
All guides
AI for HashiCorp Vault By James Joyner IV · · 9 min read Last reviewed Jul 2026

Vault Error: 'invalid ciphertext: unable to decrypt' Transit Key Version and Rewrap Issues

Quick answer

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
Free toolkit

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_version was raised past the version embedded in the ciphertext before all data was rewrapped.
  • The key uses key derivation and the context supplied 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/encrypt unencoded — Transit requires base64 input, so what round-trips is not what you expected.
  • The token’s policy grants transit/encrypt/* but not transit/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

  1. 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
  1. 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
  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
  1. 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
  1. 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"]
}
  1. Once every stored ciphertext has been rewrapped, rotate and then raise the floors together. Raising min_encryption_version forces new writes onto the new version; raising min_decryption_version retires 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.

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.

Free download · 368-page PDF

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?

Free download · 368-page PDF

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.