Why this lesson exists
"I gave the app the right policy and it still gets permission denied" is the ticket every Vault team answers every week. Nine times out of ten the policy names a path the request never uses - most often the KV v2 data/ path from the last lesson - and the tenth time a more specific rule elsewhere wins. Policies are short and readable, but Vault applies them literally. This lesson is how to write them, read them, and prove what a token can do before anyone deploys.
What you need to know already: tokens carry policies (the tokens lesson); KV v2 API paths: data/, metadata/, delete/, undelete/, destroy/ (the KV lesson); globbing with * (4.15); RBAC's idea of verbs on resources (17.30).
The words you need first
- Policy - a named set of rules. Tokens carry policy names; Vault looks the rules up on every request, so changing a policy changes every token that has it, immediately.
- Path rule -
path "<pattern>" { capabilities = [...] }: what may be done on the paths matching the pattern. - Capability - one permitted operation:
create,read,update,patch,delete,list,sudo,deny(andsubscribe,recoverfor events and snapshot recovery). - Glob - a trailing
*: the pattern matches any path that starts with what comes before it. Segment wildcard - a+in place of one whole path segment. - Deny by default - no rule matches = no access. There is nothing like an allow-all.
- root / default - the two built-in policies.
rootcan do everything and cannot be changed;defaultis attached to every token unless told otherwise.
Writing a policy
# orders-app: what the orders service needs, nothing more
path "secret/data/shop/orders/*" {
capabilities = ["read"]
}
path "secret/metadata/shop/orders/*" {
capabilities = ["list"]
}
A policy file is HCL (the same syntax family as Terraform and vault.hcl): one path block per pattern, a list of capabilities in each.
$ vault policy write orders-read /tmp/orders-read.hcl
Success! Uploaded policy: orders-read
$ vault policy list
default
lab-admin
orders-read
root
Mistakes are rejected at upload, with the real parser errors:
$ vault policy write bad /tmp/bad.hcl
Error uploading policy: Error making API request.
URL: PUT https://127.0.0.1:8200/v1/sys/policies/acl/bad
Code: 400. Errors:
* 1 error occurred:
* failed to parse policy: path "secret/data/x": invalid capability "write"
There is no write capability: writing is create (the path does not exist yet) and update (it does). A misspelt key gives invalid key "capability" on line 2; a missing bracket gives the HCL parser's At 3:1: expected: ... got: RBRACE. vault policy fmt FILE rewrites a file in the canonical layout and fails on the same errors - use it in CI before upload.
Capabilities and HTTP methods
| capability | requests it allows |
|---|---|
read | GET |
list | LIST (GET ?list=true) |
create | POST/PUT to a path that does not exist yet |
update | POST/PUT to a path that exists (and most "action" endpoints: login, renew, rotate) |
patch | PATCH (vault kv patch, vault patch) |
delete | DELETE |
sudo | on top of the others: the root-protected paths (sys/audit, enabling auth methods, sys/seal, revoke-prefix, raft snapshots...) |
deny | nothing - and it overrides every other capability in the same rule |
create vs update matters for KV: a policy with only update can change an existing secret but not make a new one; a CI job that "seeds secrets" needs create.
Which rule applies
Vault never adds up rules from different patterns. For a request it finds every rule that matches the path, picks the most specific one, and uses only that rule's capabilities. Rules with the same pattern (from several policies of the token) are merged.
"Most specific" follows five rules, in order - P1 loses to P2 when:
- P1's first
*or+comes earlier in the pattern; - P1 ends in
*and P2 does not; - P1 has more
+segments; - P1 is shorter;
- P1 is smaller in alphabetical order.
The consequence people trip on: a broad deny does not beat a more specific allow.
path "secret/*" {
capabilities = ["deny"]
}
path "secret/data/shop/orders/db" {
capabilities = ["read"]
}
$ vault token capabilities $(vault token create -policy=prio -field=token) secret/data/shop/orders/db
read
$ vault token capabilities $(vault token create -policy=prio -field=token) secret/data/shop/payments/x
deny
The exact path wins (rule 1: no wildcard at all), so the read is allowed. To forbid something whatever else a token has, put the deny on a pattern at least as specific as any allow - or keep the allow out of the policy.
Segment wildcards
+ matches exactly one segment:
$ vault token capabilities $T secret/data/shop/orders/db
read
$ vault token capabilities $T secret/data/a/b/orders/db
deny
with path "secret/data/+/orders/*". Useful for "the same sub-path in every team's folder": secret/data/+/ci/*.
Templated paths
A path can contain the identity of the token's owner:
path "secret/data/users/{{identity.entity.name}}/*" {
capabilities = ["create", "read", "update", "patch", "delete"]
}
One policy gives every person a private folder named after their entity. Since Vault 2.0 a template that renders to something containing * or + is refused with permission denied - an entity named * cannot open everyone's folder.
The KV v2 paths, once more
The single most common policy bug, side by side:
# WRONG for KV v2: no request ever uses this path
path "secret/shop/orders/*" {
capabilities = ["read", "list"]
}
$ vault token capabilities $(vault token create -policy=orders-read -field=token) secret/shop/orders/db
list, read
$ vault token capabilities $(vault token create -policy=orders-read -field=token) secret/data/shop/orders/db
deny
The CLI's vault kv get secret/shop/orders/db reads secret/data/shop/orders/db, so it fails - and it fails late: the preflight to sys/internal/ui/mounts succeeds (any rule under the mount is enough to see the mount exists), then the real read gets:
Error reading secret/data/shop/orders/db: Error making API request.
URL: GET https://127.0.0.1:8200/v1/secret/data/shop/orders/db
Code: 403. Errors:
* 1 error occurred:
* permission denied
The URL in the error is the clue: it says secret/data/..., the policy says secret/.... The KV v2 policy, complete:
path "secret/data/shop/orders/*" { # kv get / put / patch / delete (latest)
capabilities = ["create", "read", "update", "patch", "delete"]
}
path "secret/metadata/shop/orders/*" { # kv list, kv metadata get/put, kv metadata delete
capabilities = ["list", "read"]
}
path "secret/delete/shop/orders/*" { # kv delete -versions=...
capabilities = ["update"]
}
path "secret/undelete/shop/orders/*" { # kv undelete
capabilities = ["update"]
}
path "secret/destroy/shop/orders/*" { # kv destroy: think twice before giving it
capabilities = ["update"]
}
And one detail of listing: vault kv list secret/shop/orders lists secret/metadata/shop/orders/ - which secret/metadata/shop/orders/* matches (the glob includes the empty string after the slash). Listing secret/shop/ itself needs a rule that matches secret/metadata/shop/.
Proving what a token can do
Never guess. Three tools:
vault token capabilities [TOKEN] PATH- the token's capabilities on one path (your own token without the TOKEN argument).deny= nothing allowed.-output-policyon any command - the rule it would need:
$ vault kv get -output-policy secret/shop/orders/db
path "secret/data/shop/orders/db" {
capabilities = ["read"]
}
- A real test: create a token with only that policy and try, as the app would -
VAULT_TOKEN=$(vault token create -policy=orders-read -ttl=5m -field=token) vault kv get .... The test token disappears on its own.
To create a test token with policies you do not hold, your token needs sudo on auth/token/create (as lab-admin has); otherwise Vault answers * child policies must be subset of parent's policies.
Parameter constraints
A rule can also limit what may be written:
path "auth/userpass/users/*" {
capabilities = ["create", "update"]
allowed_parameters = {
"password" = []
}
}
This helpdesk policy can reset passwords but cannot change a user's token_policies (and so cannot hand out admin). denied_parameters forbids keys, required_parameters makes keys mandatory. They apply to create/update requests only - and they are the kind of detail auditors ask about.
The sudo paths
Some paths need sudo in addition to the normal capability - mostly the ones that could lock everyone out or hide activity: enabling and disabling auth methods (sys/auth/*), audit devices (sys/audit*), sys/seal, sys/rotate, sys/leases/revoke-prefix/*, listing token accessors, raft snapshots. An admin policy has sudo on those; an application never does.
Designing policies that age well
- One policy per application and environment (
orders-prod-read), named for what it grants. Attach several small policies rather than one big one. - Paths by owner:
secret/data/<team>/<app>/*makes least privilege a glob. - Policies in git, applied by a pipeline (Terraform's
vault_policy- the last lesson of this chapter). A policy edited by hand in production is drift. - Review with tokens, not by eye: CI creates a token with the policy and runs
vault token capabilitieson the paths that must and must not work.
In an interview: "A token has a policy that allows secret/app/* but vault kv get secret/app/db is denied - why?" - with KV v2 the read goes to secret/data/app/db; the policy must say path "secret/data/app/*" (and secret/metadata/app/* to list). Prove it with vault token capabilities or -output-policy.
What you can now do
- Write a policy, upload it, read the parser's errors, format it with
policy fmt. - Map HTTP methods to capabilities, including create vs update and
sudo. - Work out which rule wins for a path, and why a broad deny does not beat an exact allow.
- Write correct KV v2 policies with
data/andmetadata/. - Prove a token's access with
token capabilities,-output-policyand a test token.