In this episode we'll cover the heart of Vault's security: policies in HCL format that govern who can do what against any path. You'll learn path matching, capabilities, and how to write, apply, and test policies to a production level.

After covering the various additional secrets engines like TOTP, SSH, and AWS in episode 8, we now have many "secret storage cabinets." But there's a big question we haven't answered: who is allowed to open those cabinets, and to what extent? Without a control layer, everyone with access to Vault could read every secret — a security disaster equivalent to putting production server passwords in a public spreadsheet.
In this episode we'll cover Vault Policies — the HCL-based fine-grained access control mechanism that is the authorization gateway in Vault. This is the most decisive foundation for the security of your entire secret management system. An attacker without Vault access may be harmless; but one overly permissive policy could be the biggest hole you've ever created.
Let's start with the concept, then dive straight into practice.
A policy in Vault is a declarative document that defines the set of allowed capabilities against a set of paths. Think of a policy like an employee access card: the security card can open server doors, the software engineer card can only open the lab door, and the vendor card can't open any door except the reception room. Every token issued by Vault is associated with one or more policies, and every incoming request is checked against all the policies held by that token.
The authorization flow goes roughly like this:
GET /v1/secret/data/app).403 Permission Denied.Client Request --> Auth Method (identity) --> Policy Check (authorization) --> Secrets Engine --> StorageThe key point: authentication determines who you are, while policies determine what you're allowed to do. The two must never be mixed up, and this is what we'll build in this episode and the ones that follow.
Policies are written in HCL (HashiCorp Configuration Language), the same language as Terraform. The simplest structure consists of a path block and a list of capabilities:
path "secret/data/*" {
capabilities = ["read", "list"]
}The path "secret/data/*" block defines which path is governed, and capabilities is the list of allowed operations. A policy can contain many path blocks, and each block can have additional configuration like allowed_parameters, denied_parameters, required_parameters, as well as min_wrapping_ttl and max_wrapping_ttl (we'll cover these in episode 13).
Here's a more complete example policy for a backend application:
path "secret/data/app/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "database/creds/app-db" {
capabilities = ["read"]
}
path "pki_int/issue/app-cert" {
capabilities = ["create", "update"]
}
path "transit/decrypt/app-key" {
capabilities = ["update"]
}Notice how this policy only grants access to the app's specific needs: reading config from KV, fetching dynamic database credentials, issuing PKI certificates, and decrypting data via Transit. No access to another team's secret/data/production/*, no sudo, and no access to the sys/ paths (Vault administration).
Every operation in Vault maps to a capability. Here's the full table:
| Capability | Meaning | HTTP Operation | CLI Example |
|---|---|---|---|
create | Create new data at the path (didn't exist before) | POST | vault kv put (new data) |
read | Read data from the path | GET | vault kv get, vault read |
update | Modify existing data at the path | POST / PUT | vault kv put (existing data), vault write |
delete | Delete data from the path | DELETE | vault kv delete, vault delete |
list | List the keys at the path | LIST | vault kv list, vault list |
sudo | Privileged (root-like) operations | Various | vault auth enable, vault secrets enable |
deny | Forbid all operations at the path (always wins) | — | — |
Important
Note that list does not automatically allow read, and vice versa — they are separate capabilities. Often you grant list so people can see the names of existing secrets, but can't open their contents. This is a very useful pattern for discovery without data exposure.
There are three capabilities that often confuse beginners:
sudo — doesn't mean "can do everything." sudo allows operations that require privileged rights (like enabling secrets engines, changing policies, or managing audit). Paths that require sudo include sys/policies/*, sys/secrets/*, and sys/audit/*.deny — the only capability that always wins, even over other capabilities granted on the same path from a different policy. Remember this principle: deny wins. We'll go deeper in the pitfalls section.read + update on the same path is the most common combination for applications: reading config and updating certain values without being able to delete them.Policy paths don't have to be exact — Vault supports three matching patterns that determine access granularity:
| Pattern | Example | Matches | Doesn't match |
|---|---|---|---|
| Exact path | secret/data/app | only secret/data/app | secret/data/app/env, secret/data/apps |
Prefix wildcard * | secret/data/app/* | all paths under the prefix | secret/data/app (the parent path itself) |
Segment wildcard + | secret/+/app | secret/staging/app, secret/prod/app (one segment) | secret/staging/dev/app (two segments) |
One thing you must understand: secret/data/app/* does NOT match secret/data/app. This is a classic mistake. If you want to allow access to the parent path along with all its descendants, you need two blocks:
path "secret/data/app" {
capabilities = ["read", "list"]
}
path "secret/data/app/*" {
capabilities = ["read", "update", "delete", "list"]
}The matching patterns above apply to all Vault paths, including system paths like sys/ and auth paths like auth/. Choosing these patterns carefully is what separates a safe policy from a leaking one.
Warning
Beware of overly broad prefix wildcards, like secret/data/* or even * with ["read"]. The wider the pattern, the bigger the blast radius if that token leaks. Always start from the most specific path possible, then loosen only if truly needed.
data/ SubpathThis is one of the biggest sources of confusion in Vault. When you enable kv-v2 at the secret path, the data is actually stored under secret/data/..., while version metadata lives under secret/metadata/.... This means:
secret/data/<path> (capability read)secret/metadata/<path> (capability list)secret/metadata/<path> (capability delete)secret/destroy/<path> (capability update)For vault kv get secret/app to work, your token needs read on secret/data/app. Meanwhile, vault kv list secret/ needs list on secret/metadata/. This is why many people write a secret/data/* policy and then wonder why vault kv list fails — because the list command touches the metadata path, not the data path.
path "secret/data/app/*" {
capabilities = ["create", "read", "update", "delete"]
}
path "secret/metadata/app/*" {
capabilities = ["list", "delete"]
}
path "secret/destroy/app/*" {
capabilities = ["update"]
}Now we enter the practice section. All the commands below are run with a token holding the root policy (usually the dev-mode token from episode 0).
1. Writing a policy to Vault
vault policy write app-backend policy/app-backend.hclSuccess! Uploaded policy: app-backend2. Viewing the list and contents of policies
vault policy listadmin
app-backend
default
rootTo see a policy's full contents:
vault policy read app-backend3. Creating a token that uses that policy
vault token create -policy=app-backendKey Value
--- -----
token hvs.CAESIHK7...<truncated>
token_accessor SH4P2q4VzaCdbXZqGcCfp2A3
token_duration 768h
token_renewable true
token_policies ["app-backend" "default"]
identity_policies []
policies ["app-backend" "default"]Notice that token_policies contains app-backend and default — Vault always attaches the default policy to every token. Don't delete the default policy; it contains the basic paths tokens need for normal function (like renewing their own token).
4. Testing privileges with vault token capabilities
This command is your "detective" — it shows what capabilities a given token has against a path, without actually sending the request:
export VAULT_TOKEN=hvs.CAESIHK7... # token from the output above
vault token capabilities secret/data/app
vault token capabilities secret/data/production/dbread list
denyThe first output shows the token can read and list at secret/data/app, while the second shows deny — because the token has no access to that path. With this command you can verify policies before actually using them, which is very useful when debugging.
5. Testing with real requests
vault kv get secret/app
vault kv put secret/app/secret-key value="rahasia"Operations allowed by the policy will succeed, and those that aren't will produce:
Error writing data to secret/data/app/secret-key: Error making API request.
URL: PUT http://127.0.0.1:8200/v1/secret/data/app/secret-key
Code: 403. Errors:
* 1 error occurred:
* permission deniedFor easier recall, here's a map of common operations you'll most often need:
| Command | Path Touched | Required Capability |
|---|---|---|
vault kv get secret/app | secret/data/app | read |
vault kv put secret/app x=y | secret/data/app | create (new) / update (existing) |
vault kv delete secret/app | secret/data/app | delete |
vault kv list secret/ | secret/metadata/ | list |
vault kv metadata get secret/app | secret/metadata/app | read |
vault kv destroy -versions=1 secret/app | secret/destroy/app | update |
vault read database/creds/app-db | database/creds/app-db | read |
vault write pki_int/issue/app-cert ... | pki_int/issue/app-cert | create, update |
One token can carry many policies. When Vault checks access, all policies are combined with union semantics — meaning capabilities are merged, not restricted. If policy A grants read and policy B grants update on the same path, the result is the token has both read and update.
But remember: deny is the exception. If one policy has deny on a path, that deny takes effect even if another policy grants read.
Real-world example: you have an app-backend policy (granting broad access to secret/data/app/*) and want to create a token that can only read production data, not delete it:
path "secret/data/app/production/*" {
capabilities = ["read", "list"]
}When the token carries app-backend + app-block-delete, access to secret/data/app/production/* becomes deny (because deny wins), while the rest of secret/data/app/* still follows app-backend. This allow-list + block-list pattern is very powerful for emergency lockdown without having to rewrite the main policy.
Let's put it all together in a simple scenario. Suppose Vault is used by three groups with different needs:
| Group | Need | Policy |
|---|---|---|
| Backend application | Read app config, get DB creds, issue certs | app-backend |
| Developers | Read + write config in the staging env, list all | dev-kv |
| Platform admins | Manage Vault itself | admin |
path "secret/data/staging/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "secret/metadata/staging/*" {
capabilities = ["list"]
}path "sys/*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}vault policy write app-backend policy/app-backend.hcl
vault policy write dev-kv policy/dev-kv.hcl
vault policy write admin policy/admin.hclThen create a token for each and test with vault token capabilities:
export VAULT_TOKEN=$(vault token create -policy=dev-kv -format=json | jq -r '.auth.client_token')
vault token capabilities secret/data/staging/api-key
vault token capabilities secret/data/production/api-keyExpected output: the dev token can create read update delete list on the staging path, and deny on the production path.
| Mistake | Symptom | Solution |
|---|---|---|
Forgetting the data/ subpath for kv-v2 | vault kv get fails even though the policy looks right | Remember: data at secret/data/, metadata at secret/metadata/ |
| Prefix wildcard doesn't include the parent path | Access to the path itself is denied | Write two blocks: parent path + parent path /* |
Giving list when read was intended | Users can see secret names but not their contents | Add the read capability if needed |
deny placed in a policy the token doesn't carry | The blockade doesn't apply | Make sure deny wins: put deny in a policy that's always attached |
Missing capability (e.g. read without list) | List operation fails | Check which path the command touches via vault token capabilities |
Overly broad policy (* with ["read"]) | Blast radius grows if the token leaks | Narrow it to specific paths and use least privilege |
| Forgetting to re-apply after editing the file | The old policy is still active | Re-run vault policy write after changing the file |
Tip
Make vault token capabilities <token> <path> your ritual before deploying a new policy. First verify that access which should be allowed is allowed, then check that access which should be denied is indeed denied. These two lines of command save hours of debugging in production.
In this episode we've covered Vault's authorization foundation: HCL-based policies with path blocks and capabilities, the difference between create/read/update/delete/list/sudo/deny, path matching rules (exact, prefix wildcard, segment wildcard), how to apply policies with vault policy write, and testing them with vault token capabilities. We also explored classic pitfalls like forgetting the data/ subpath in KV v2, deny always winning, and union semantics when a token carries multiple policies.
The essence of this episode is one principle: policies are the outermost and most important security boundary in Vault. Writing them with the least privilege principle — starting from the narrowest path, granting the minimal capability, then loosening only when truly needed — is a habit that will save you from many incidents.
In episode 10, we'll answer the "who" question we locked down in this episode: Authentication Methods (Token, Userpass, AppRole, OIDC) — how users and machines identify themselves to Vault to obtain the policy-constrained tokens discussed here. Keep your enthusiasm up!