Vault’s "Cubbyhole" pattern allows you to deliver secrets to applications or users without exposing them directly to the initial requestor.
Let’s say you have a service that needs a database password. Instead of the service directly asking Vault for the password and potentially having that request intercepted or logged, the service’s operator (or another authorized entity) can use Vault to "wrap" the secret. This wrapped secret is then given to the service. When the service starts, it can then "unwrap" the secret, but only if it presents its own identity to Vault.
Here’s how it looks in practice. Imagine we want to give a database/config secret to an application named my-app.
First, we need to wrap the secret. We’ll use the vault write command to do this. The kv/data path is where secrets are typically stored in the KV v2 engine.
vault write auth/token/create display_name=my-app-initializer policies=my-app-read-db-policy \
ttl=1m | tee token_response.json
This creates a temporary token for the initializer. Now, we use this token to wrap the secret.
vault kv get -wrap-ttl=10s -mount-path=secret -format=json kv/data/database/config \
| vault write -field=wrap_info sys/wrapping/wrap -format=json \
-child-token-accessor=$(jq -r '.auth.accessor' token_response.json) \
-child-token-policies=$(jq -r '.auth.policies | join(",")' token_response.json) \
-child-token-ttl=$(jq -r '.auth.lease_duration' token_response.json)
The output of this command is a wrapping_token. This is what we give to my-app.
{
"request_id": "...",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"token": "s.abcdef1234567890",
"accessor": "...",
"policies": ["default", "my-app-read-db-policy"],
"token_policies": ["default", "my-app-read-db-policy"],
"metadata": {
"entity_id": "...",
"token_type": "service",
"creation_time": "...",
"bound_audiences": null,
"bound_explicit_bound_cidrs": null,
"bound_implicit_bound_cidrs": null,
"bound_ip_addresses": null,
"bound_mac_addresses": null,
"bound_num_uses": "0",
"bound_ttl": "1m",
"direct_role": null,
"display_name": "my-app-initializer",
"id": "...",
"num_uses": "0",
"orphan": false,
"period": "0",
"renewable": true,
"role_name": "",
"explicit_max_ttl": "0",
"max_ttl": "0",
"no_default_policy": false,
"path": "auth/token/create",
"reauth_token_id": null,
"user_fp": null,
"user_id": null,
"metadata": {},
"safeguard": null,
"token_auth_methods": []
},
"wrap_info": {
"token": "hvs.abcdef1234567890",
"accessor": "...",
"policies": ["default", "my-app-read-db-policy"],
"token_policies": ["default", "my-app-read-db-policy"],
"metadata": {
"request_id": "...",
"job_id": "...",
"task_id": "...",
"original_request": "...",
"original_request_id": "...",
"vault_namespace": "...",
"vault_namespace_id": "...",
"vault_namespace_path": "...",
"vault_namespace_tree": "...",
"vault_namespace_tree_ids": "...",
"vault_namespace_tree_paths": "..."
},
"ttl": "10s",
"creation_time": "...",
"display_name": "my-app-initializer",
"id": "...",
"num_uses": "0",
"orphan": false,
"period": "0",
"renewable": true,
"explicit_max_ttl": "0",
"max_ttl": "0",
"no_default_policy": false,
"path": "auth/token/create",
"reauth_token_id": null,
"user_fp": null,
"user_id": null,
"token_auth_methods": []
}
},
"warnings": null
}
The wrap_info.token is the actual secret that was wrapped. This is what my-app receives.
Now, when my-app starts up, it has this wrapping token. It uses its own identity (e.g., a Kubernetes service account, an AWS IAM role, or a Vault-authenticated client token) to authenticate with Vault. Then, it unwraps the secret.
# Assume my-app has its own authenticated Vault client token in VAULT_TOKEN
vault read -field=database/config sys/wrapping/unwrap \
-child-token-accessor=<accessor_from_wrap_info> \
-child-token-policies=<policies_from_wrap_info> \
-child-token-ttl=<ttl_from_wrap_info> \
-token=<wrapping_token_from_wrap_info>
This command uses the wrapping_token to request the unwrapping. Vault will then check if the identity making the unwrap request is authorized to receive the secret. If my-app’s identity (via its authenticated token) is permitted, Vault will decrypt and return the secret. The wrapping token is consumed in this process.
The core idea is that the initial requestor doesn’t see the secret. The secret is only revealed when the intended recipient (identified by its authenticated Vault identity) unwraps it. This significantly reduces the attack surface, as the secret never traverses the network in plain text from Vault to the final consumer in a way that could be intercepted by an unauthorized party.
The wrap_info object contains the actual secret data, encrypted. When you unwrap, Vault decrypts this data using the wrapping token, and then re-encrypts it with the token provided by the unwrapping client. The critical part is that the unwrapping client’s token must have permissions to access the secret that was originally wrapped.
The most surprising thing about the cubbyhole pattern is that the secret itself isn’t directly stored in the "cubbyhole" but rather a reference to it, encrypted with a temporary token. The actual secret remains in its original location (e.g., secret/data/database/config), and the wrapping process creates a temporary, encrypted payload that can only be decrypted by a Vault token authorized to access that original secret.
This mechanism allows for fine-grained control over secret delivery. You can even use different authentication methods for the initial wrapping and the final unwrapping. For instance, a CI/CD pipeline might wrap a secret using a CI-specific token, and then an application running in Kubernetes would unwrap it using its Kubernetes service account identity.
The sys/wrapping/unwrap endpoint is designed to be idempotent but will only succeed once. Subsequent attempts with the same wrapping token will fail. This ensures that a secret is delivered only once.
The next thing you’ll likely run into is managing the lifecycle of these wrapping tokens and ensuring the unwrapping client has the correct authentication and authorization to access the secrets once they are unwrapped.