ACL policies that scale

Path capabilities, identity, and least privilege.

Intermediate30 min · lesson 5 of 13

A contractor needs read access to one database password on a Friday afternoon. The person holding the root token is on call, the ticket is two days old, and the quickest way out is a policy called dev that hands over every capability on secret/*. Everyone gets their weekend back. Eight months later that policy sits on nineteen tokens, four of them inside build pipelines, and one of those tokens has been printed into a build log that anyone in the company can read.

Hotels worked this out decades ago. Housekeeping carries a card that opens the rooms on one floor during one shift, not the master key that also opens the manager's safe. A Vault ACL policy (access control list, a written list of which doors open, for whom, and in which way) is that housekeeping card written down as text. It names paths in Vault's API (application programming interface, the set of web addresses Vault answers requests on) and, for each path, the operations you are allowed to perform there.

Logging in answers who you are. Policies answer what you may touch, and nothing else in Vault answers that second question for you. A cluster with wide-open policies is a nicely encrypted database of secrets with a login screen bolted to the front. You will see how Vault matches a request against your rules, where that matching quietly surprises people, how to hang policies off identity instead of stapling them to individuals, and how to prove a policy is as narrow as you think it is.

Default Deny Is The Starting Position

Vault starts from no. If no rule in any policy attached to your token grants a capability on the exact path you asked for, the request comes back as HTTP 403 (the web status code meaning forbidden) and nothing happens. There is no inheritance from a parent path, no admin escape hatch, no silent fallback to root. That default is what makes small policies safe to write: a rule you forgot is a door that stays shut, never one that swings open.

You author policies in HCL (HashiCorp Configuration Language, HashiCorp's block-and-key configuration syntax) or in equivalent JSON (JavaScript Object Notation). vault policy write stores them at the API path sys/policies/acl/<name>; the older sys/policy endpoint still answers for the sake of legacy tooling, and nothing new should be built on it. Names are case-insensitive and stored lowercase. Two names are reserved: root cannot be edited or deleted, and default is attached to every token Vault issues unless somebody deliberately turns it off.

terminal
vault policy list
vault policy read default
output
app-billing
app-payments
app-webapp
default
ops-break-glass
root
# vault policy read default (trimmed: comments and roughly ten more stanzas removed)
path "auth/token/lookup-self" {
capabilities = ["read"]
}
path "auth/token/renew-self" {
capabilities = ["update"]
}
path "sys/capabilities-self" {
capabilities = ["update"]
}
path "sys/leases/renew" {
capabilities = ["update"]
}
path "cubbyhole/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "sys/internal/ui/resultant-acl" {
capabilities = ["read"]
}

Eight capabilities cover nearly everything you will ever write. They are the verbs on the key card. create and update both cover writes, and there is a wrinkle worth knowing: most Vault endpoints treat every write as update, but a few, the key/value engine among them, check create when there is nothing at the path yet. Grant both on anything that writes secrets and you avoid a first-write 403 that only shows up in production. read fetches a value. list returns the names under a prefix and does not imply read, so a token can see that db-prod exists without being allowed to open it, which is exactly what an inventory job wants. delete removes. patch (Vault 1.9 and later) allows a partial update without handing over a full overwrite. sudo is a second key you need alongside the normal capability on a short list of root-protected endpoints such as sys/seal. deny refuses, and on the rule that matches, deny beats every other capability there.

Paths Are Not Folders

A policy path looks like a folder path, and that is the trap. It behaves more like a mail sorting rule matching the text of an address. path "secret/data/webapp" matches one address and nothing else. It covers secret/data/webapp/config no more than a rule for "12 Oak Street" covers "12 Oak Street, Flat 3".

Two wildcards change that. * is a glob, and Vault accepts it only as the very last character of a path. It matches anything starting with the text in front of it, so secret/data/webapp/* covers secret/data/webapp/config and secret/data/webapp/db/creds, but not secret/data/webapp itself, because that string has no trailing slash. Drop the slash, write secret/data/webapp*, and you cover both, along with secret/data/webapp-legacy, which is probably not what you had in mind. + is the other wildcard. It stands for exactly one path segment, it can sit anywhere in the path, and you can use several of them, so secret/data/+/shared-ca-bundle matches that one key under every application prefix without opening the rest of the mount.

When several rules could match one request, Vault does not merge them. It ranks them and applies exactly one. A path with no wildcard beats a path containing +, which beats a path ending in *. Between two + paths, the one whose wildcard sits further to the right wins; between two globs, the longer prefix wins. Capabilities are pooled only when two policies use the identical pattern, and if either of them says deny, the answer is deny. That ranking is what makes carve-outs work: a broad glob granting reads, plus an exact-path deny on the one secret nobody should read, gives you the deny, because the exact path outranks the glob.

policies/app-billing.hcl
# The app's own config: the exact node AND everything under it.
path "secret/data/billing" {
capabilities = ["read"]
}
path "secret/data/billing/*" {
capabilities = ["read"]
}
# One shared key under every application prefix, without opening the mount.
path "secret/data/+/shared-ca-bundle" {
capabilities = ["read"]
}
# Carve-out: an exact path outranks the glob above, and deny wins its own rule.
path "secret/data/billing/root-db-password" {
capabilities = ["deny"]
}
A trailing star does not include its own prefix
path "secret/data/webapp/*" grants nothing at all on secret/data/webapp. Teams meet this the day somebody stores a value at the top of an application's prefix instead of one level down, and the 403 reads like a Vault bug because the policy "obviously" covers that tree. Write both stanzas, or accept the wider secret/data/webapp* and know it also matches sibling prefixes such as secret/data/webapp-staging. The same shape of mistake bites listing: seeing the names under secret/metadata/webapp needs a rule matching secret/metadata/webapp itself, because the LIST request is made against the prefix, not against its children.

Write It, Then Prove It

Here is a policy for one machine: read the application's own configuration, mint database credentials from one named role, and keep its own token alive. Nothing else.

terminal
vault policy write app-webapp - <<'EOF'
path "secret/data/webapp/*" {
capabilities = ["read"]
}
path "database/creds/webapp-rw" {
capabilities = ["read"]
}
path "auth/token/renew-self" {
capabilities = ["update"]
}
EOF
vault policy read app-webapp
output
Success! Uploaded policy: app-webapp
path "secret/data/webapp/*" {
capabilities = ["read"]
}
path "database/creds/webapp-rw" {
capabilities = ["read"]
}
path "auth/token/renew-self" {
capabilities = ["update"]
}

Three details in that small file earn their keep. database/creds/webapp-rw is a read even though every call creates a brand new database user, because the database engine exposes credential minting behind a GET (the HTTP verb for fetching something). Path shapes come from the engine, so read the engine's own documentation before you invent a wildcard. Those dynamic credentials arrive attached to a lease, and renewing a lease goes through sys/leases/renew, which default already grants to any token that knows the lease identifier; handing the credentials back early goes through sys/leases/revoke, which default does not grant. And auth/token/renew-self looks redundant, since default grants it too. Production machine roles often set token_no_default_policy=true to keep the blast radius small, and the moment default is gone the token can no longer renew itself, look itself up, or ask what it is allowed to do. The explicit stanza keeps the policy working the day somebody tightens the role.

If a Vault Agent is doing the logging in and the template rendering for this application, this policy is the ceiling on its templates as well. The agent renders with the token it obtained through auto-auth, so a template referencing a path this policy does not cover fails at render time, not at deploy time.

terminal
vault token create -policy=app-webapp -ttl=15m
output
Key Value
--- -----
token hvs.k7Qm2ZrTb9WfLx4Ns8VpDcY1
token_accessor 4mQ0y7Xk2sB1nJp9Tz6wLdRe
token_duration 15m
token_renewable true
token_policies ["app-webapp" "default"]
identity_policies []
policies ["app-webapp" "default"]

Three fields, three meanings. token_policies are the ones asked for at login or at token creation, and they are fixed into the token. identity_policies come from the caller's identity entity and its groups, and they are looked up fresh on every single request, which is why adding a policy to a group takes effect immediately on tokens that already exist. policies is the pooled set that actually applies. For a human logging in through an identity provider, token_policies is often only default and everything real turns up in identity_policies, which makes this a fast triage step at three in the morning: an empty identity_policies on a human login means the group mapping broke, not the policy.

Now stop reading the policy and start interrogating it. vault token capabilities asks Vault what a token may actually do on a given path, which is the only answer that counts.

terminal
export VAULT_TOKEN='hvs.k7Qm2ZrTb9WfLx4Ns8VpDcY1'
vault token capabilities secret/data/webapp/config # child of the glob
vault token capabilities secret/data/webapp # the prefix itself
vault token capabilities secret/metadata/webapp # listing and version history
vault token capabilities database/creds/webapp-rw # dynamic database credentials
output
read
deny
deny
read

Four lines that would otherwise have cost you an afternoon in production. With no token argument the command asks about the token in your environment through sys/capabilities-self, which default grants, so a token stripped of default cannot run this check on itself. As an operator you can ask about somebody else's session without ever holding their token, using the accessor Vault printed when the token was created. Watch the syntax here, because it catches people: -accessor is a plain on/off flag and the accessor itself is the argument, so it reads vault token capabilities -accessor 4mQ0y7Xk2sB1nJp9Tz6wLdRe secret/data/webapp/config, and that call needs update on sys/capabilities-accessor. Keep a handful of these assertions in your pipeline beside the policy files and a widened wildcard shows up as a failing test instead of an incident.

The KV v2 Path Trap

KV v2 (the key/value secrets engine, version two) keeps the contents and the index in separate rooms, the way a library keeps books on shelves and titles in a catalogue. Secret bytes live under secret/data/.... Version history, custom labels and the list of names live under secret/metadata/.... Three more prefixes drive the lifecycle: secret/delete/..., secret/undelete/... and secret/destroy/.... The vault kv commands hide all of this behind friendlier paths, which is why KV v2 produces more baffling 403s than every other engine combined.

The translation is what your policy has to match. vault kv get secret/webapp/config is a GET on secret/data/webapp/config. vault kv list secret/webapp is a LIST on secret/metadata/webapp. vault kv put writes to secret/data/... and wants create and update together. vault kv delete secret/webapp/config marks the newest version deleted, needs delete on the data path, and is reversible; deleting a specific older version with -versions=2 posts to secret/delete/webapp/config and needs update there instead. vault kv undelete -versions=3 needs update on secret/undelete/webapp/config. vault kv destroy -versions=3 erases those version bytes for good and needs update on secret/destroy/webapp/config. vault kv metadata delete wipes every version and the history with it, and needs delete on secret/metadata/webapp/config. An application that writes its own secrets has no business holding those last two.

policies/app-payments.hcl
# Read and write the app's own secrets; see version history.
path "secret/data/payments/*" {
capabilities = ["create", "read", "update", "patch"]
}
path "secret/metadata/payments/*" {
capabilities = ["read", "list"]
}
path "secret/metadata/payments" {
capabilities = ["list"]
}
# Recoverable delete only: no destroy, no metadata delete.
path "secret/delete/payments/*" {
capabilities = ["update"]
}
path "secret/undelete/payments/*" {
capabilities = ["update"]
}
path "secret/destroy/payments/*" {
capabilities = ["deny"]
}

That closing deny is a guardrail with a known range, not a wall. It stops a second policy on the same token from granting secret/destroy/payments/*, because identical patterns pool their capabilities and deny wins the pool. It does not stop a policy granting the exact path secret/destroy/payments/config, because an exact path outranks a glob. Guardrails like this are worth writing and worth understanding well enough that you never trust them past their range.

Policies can constrain the body of a request too, not only the address. required_parameters, allowed_parameters and denied_parameters restrict which fields a caller may send, and a non-zero min_wrapping_ttl forces the caller to take the answer as a response-wrapping token, so the secret travels inside a one-time envelope. These act on the top level of the request body, which for KV v2 is data and options rather than your individual secret keys, so they fit engines like database/ and pki/ (public key infrastructure, the engine that issues certificates) far better than they fit key/value.

Attach Policies To Identity, Not To People

An office does not re-cut every badge when somebody joins the payments team. It adds them to a group, and the door readers already know what that group opens. Vault works the same way, in two layers. Auth roles carry token_policies, which suits machines, because a role is a machine's job description. Humans should draw their policies from identity groups, so that adding a person to platform-oncall in your identity provider is the entire change.

Every successful login creates or matches an identity entity, which is Vault's single record of one person or workload across all auth methods. Each login method contributes an alias to that entity, so Priya arriving through OIDC (OpenID Connect, the sign-in protocol your identity provider speaks) and a token issued to her laptop hang off the same entity. Groups hold the policies. An internal group keeps a member list you maintain inside Vault. An external group keeps no member list of its own: it maps a group name asserted by your identity provider onto a Vault group, and Vault recalculates that membership when the person logs in.

Wiring one up takes two writes and the mount accessor of your OIDC auth method, which you can pull out with jq (a command-line tool for picking fields out of JSON).

terminal
OIDC_ACCESSOR=$(vault auth list -format=json | jq -r '."oidc/".accessor')
GROUP_ID=$(vault write -field=id identity/group \
name="platform-oncall" type="external" policies="app-webapp,ops-read")
vault write identity/group-alias \
name="vault-platform-oncall" \
mount_accessor="$OIDC_ACCESSOR" \
canonical_id="$GROUP_ID"
output
Key Value
--- -----
canonical_id 9f2a1c74-6b0e-4a11-9b3f-51a0d7e0c2aa
id 5e3b8d20-0c9e-4f7c-91a8-2fb4de6a1c33

The alias name has to match the group string your provider actually sends in the claim your OIDC role reads, and that is the part people get wrong. A group displayed as platform-oncall in the provider console can arrive as vault-platform-oncall, or as a long directory identifier nobody recognises. After the first real login, read the group back with vault read identity/group/name/platform-oncall and check member_entity_ids. Empty means the mapping missed and that person is quietly running with default and nothing else.

Here is the sharp edge, stated plainly. Vault refreshes external group membership at login. Remove somebody from the group in your identity provider and Vault's own copy still lists them, so their live token keeps working exactly as before until it expires. Identity policies really are recomputed on every request, but they are recomputed from Vault's record, not from your provider's, and that record does not change until the next login. An internal group behaves the opposite way: the member list lives in Vault, so removing a person there lands on the very next request. Offboarding through an external group is therefore two steps. Change the group in the provider so the next login is clean, then deal with the credentials that already exist: vault write identity/entity/id/<entity-id> disabled=true stops that entity's existing tokens from being used and blocks new logins through its aliases, and vault token revoke -accessor 4mQ0y7Xk2sB1nJp9Tz6wLdRe ends one specific session (-accessor is an on/off flag here too). A short token TTL (time to live, how long a credential stays valid before Vault stops honouring it) shrinks the window, which is the honest argument for hour-long human sessions rather than week-long ones.

Templated policies close the last gap. Rather than one policy per person, you write one policy holding a placeholder that Vault fills in from the caller's identity at the moment of the request.

policies/user-scratch.hcl
# Every engineer gets a private prefix, from a single policy.
path "secret/data/user/{{identity.entity.id}}/*" {
capabilities = ["create", "read", "update", "delete", "patch"]
}
path "secret/metadata/user/{{identity.entity.id}}/*" {
capabilities = ["read", "list", "delete"]
}
# Team space keyed by group, resolved per caller at request time.
path "secret/data/team/{{identity.groups.names.platform-oncall.id}}/*" {
capabilities = ["create", "read", "update"]
}

Somebody who is not a member of platform-oncall gets nothing from that last stanza, because the placeholder never resolves and Vault skips the rule rather than guessing. Entity identifiers make ugly paths, and the alternative {{identity.entity.aliases.<mount_accessor>.name}} uses the login name from one auth mount instead, at the cost of hard-coding that mount's accessor into the policy text, which breaks the day the mount is recreated.

How Vault authorizes one request
1Request arrives
token in the X-Vault-Token header
2Policies resolved
token policies, plus entity and group policies looked up now
3Rules combined
identical paths pool capabilities; templates render per caller
4One rule wins
exact beats +, which beats a trailing *
5Capability checked
missing capability or deny returns 403
6Audit trail
request logged before it runs, response after; allowed or refused, both appear
Exactly one rule decides the request. Two policies using the identical path pool their capabilities and a deny in either kills it, but a more specific allow still outranks a broader deny.

Prove The Blast Radius

A policy you have not tested against a real token is a guess. Take the fifteen-minute token from earlier, read the secret it is supposed to reach, then reach for a neighbour's secret and watch what comes back.

terminal
export VAULT_TOKEN='hvs.k7Qm2ZrTb9WfLx4Ns8VpDcY1'
vault kv get secret/webapp/config
vault kv get secret/payments/config
vault read secret/data/payments/config
output
== Secret Path ==
secret/data/webapp/config
======= Metadata =======
Key Value
--- -----
created_time 2026-07-24T09:41:12.442Z
custom_metadata <nil>
deletion_time n/a
destroyed false
version 3
====== Data ======
Key Value
--- -----
dsn postgres://webapp@db.internal:5432/webapp
log_level info
Error making API request.
URL: GET https://vault.internal:8200/v1/sys/internal/ui/mounts/secret/payments/config
Code: 403. Errors:
* preflight capability check returned 403, please ensure client's policies grant access to path "secret/payments/config/"
Error reading secret/data/payments/config: Error making API request.
URL: GET https://vault.internal:8200/v1/secret/data/payments/config
Code: 403. Errors:
* permission denied

Two refusals, two different messages, and the first one sends people down the wrong hole for hours. Before fetching a key/value secret, the command line asks sys/internal/ui/mounts/<path> whether that mount is version 1 or version 2, because the two versions need different API paths. That helper endpoint refuses with 403 when your token holds no capability anywhere on the path you named, so the error quotes a sys/ address you never typed. The same 403 comes back for a mount that does not exist, even with a root token, which means "preflight capability check returned 403" translates about half the time to "you spelled the mount name wrong". Repeat the request against the full API path with vault read and Vault gives you the plain answer: permission denied, on the neighbouring path, exactly as designed.

Ship Policies Like Code

Policies belong in a repository, applied by a pipeline, reviewed as diffs. That pipeline needs a token of its own, and the token it gets is where most teams quietly rebuild the problem they set out to solve. Scope it to a policy name prefix and withhold the ability to delete.

policies/ci-policy-writer.hcl
# The pipeline may create and update application policies. Nothing else.
path "sys/policies/acl/app-*" {
capabilities = ["create", "read", "update", "list"]
}
path "sys/policies/acl" {
capabilities = ["list"]
}
# Human-facing policies stay a human decision, with a change record.
path "sys/policies/acl/ops-*" {
capabilities = ["deny"]
}

That last stanza changes nothing on its own, since nothing here granted ops-* in the first place. It is there to block a second policy on the same token that grants the identical pattern, and to tell the next reviewer what the intent was. Write access to policies is the escalation to guard hardest. Vault already blocks the obvious ladder: a token can only create a child token carrying a subset of its own policies, unless it is root or holds sudo on auth/token/create. Policy writing has no such rule. Anyone who can write to sys/policies/acl/* can rewrite the body of a policy their own token already carries, add sudo on sys/* to it, and be operating as root on the very next request without their token's policy list changing at all. Keep that path off every application policy, scope the pipeline as above, and alert on the writes.

terminal
jq -r 'select(.type == "response" and (.request.path | startswith("sys/policies/acl/")))
| [.time, .auth.display_name, (.auth.metadata.role_name // "-"),
.request.operation, .request.path] | @tsv' \
/var/log/vault/audit.log
output
2026-07-24T02:11:07.881Z approle vault-ci update sys/policies/acl/app-webapp
2026-07-26T18:03:44.190Z oidc-priya - update sys/policies/acl/ops-break-glass
2026-07-27T09:02:15.663Z approle vault-ci update sys/policies/acl/app-payments

The middle line is the one that should page somebody. A human, from an interactive session, editing the break-glass policy on a Sunday evening, outside the pipeline that is supposed to own every policy in the cluster. The other two rows are the pipeline doing its job under the vault-ci AppRole, and they are the reason you can tell the difference at a glance. Audit devices record the paths in the clear and hash the token values, so this kind of query is safe to run and safe to ship to your log platform.

Wildcards hide escalations
path "secret/data/*" { capabilities = ["read","create","update","delete"] } is the policy everybody writes on day one and nobody rewrites on day four hundred. It hands one application every other team's secrets, including whatever a pipeline parked there, and it hands the same to an attacker who lands in that single container. Two patterns are worse. path "sys/*" with sudo is a root token with extra typing: seal the cluster, read raw storage, rewrite the audit configuration. path "*" with every capability is not literally the root policy, because root-protected endpoints still want sudo, but it includes sys/policies/acl/*, so it is one policy write away from root and should be treated as root. Scope to an application prefix, keep break-glass policies unassigned and alarmed, and revoke the initial root token once setup is finished so the only route back is vault operator generate-root with a quorum of your unseal or recovery key holders in the room.

Small policies cost something, and pretending otherwise is how teams end up back at secret/*. You will hit a 403 mid-incident for a capability nobody thought to grant, you will maintain more files, and a policy per application means a review queue. What makes that survivable is faster feedback rather than fewer policies: one repository with a naming convention (app-* for workloads, ops-* for people), vault token capabilities assertions running in the pipeline so a widened wildcard fails review, and one documented break-glass policy that pages the instant it is used. Refusing by default with a loud emergency door beats a permissive policy nobody dares to tighten.

Next: KV v2 itself, where versioning and check-and-set decide whether a careless deploy quietly overwrites the secret every other service is reading.

Quick check
01A token carries app-webapp and default. app-webapp grants read on secret/data/webapp/*, and no policy mentions secret/data/reports/daily. The application reads that path. What happens?
Incorrect — Vault refuses the request. The audit device records refusals, it never converts one into an allow.
Incorrect — default covers self-lookup, self-renewal, lease renewal, capability checks and the token's cubbyhole. It never touches your key/value data.
Correct — no matching rule means no access. Refusal is the starting position, not a setting you switch on.
Incorrect — root is a policy you attach to a token. It is never a fallback for unmatched paths.
02A policy contains only path "secret/data/webapp/*" { capabilities = ["read"] }. Somebody stored a value directly at secret/webapp, and vault kv get secret/webapp now fails. What is the narrowest change that fixes it?
Incorrect — the metadata path returns version information and names, never the secret contents, and it has no effect on matching for the data path.
Correct — a trailing glob covers what sits under secret/data/webapp/. The prefix itself is a different address and needs its own rule.
Incorrect — sudo is an extra key for root-protected endpoints such as sys/seal, and it changes nothing about how paths are matched.
Incorrect — it works, and it also hands the token every other team's secrets in that mount, which is the opposite of narrow.
03A teammate's token reads secret/webapp/config fine, but vault kv get secret/payments/config prints: preflight capability check returned 403, please ensure client's policies grant access to path "secret/payments/config/". What is the right move?
Incorrect — Vault rebuilds the rule set from the token's policies on every request. There is no stale cache to clear.
Incorrect — that treats the symptom. The helper endpoint refuses because the token holds no capability at all on the key/value path it asked about.
Incorrect — sudo applies to root-protected endpoints and would widen the token for nothing.
Correct — the same 403 comes back for a mount that does not exist, even for a root token, so confirm the path before you widen anything.

Try this

Run vault policy list on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a trailing star does not include its own prefix. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related