Learn Vault - Vault Policies (HCL & Fine-Grained Access Control)
Episode 9 of 26

Learn Vault - Vault Policies (HCL & Fine-Grained Access Control)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

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.

Main Discussion

Basic Concept: A Policy Is a Contract Between a Token and Paths

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:

  1. The client sends a request (e.g. GET /v1/secret/data/app).
  2. Vault finds all policies attached to the token.
  3. Vault matches the request path against the paths defined in the policies.
  4. If the required capability is available → the request is allowed. If not → 403 Permission Denied.
plaintext
Client Request --> Auth Method (identity) --> Policy Check (authorization) --> Secrets Engine --> Storage

The 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.

Anatomy of an HCL Policy

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:

policy/read-only.hcl
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:

policy/app-backend.hcl
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).

Capabilities: Allowed Operations

Every operation in Vault maps to a capability. Here's the full table:

CapabilityMeaningHTTP OperationCLI Example
createCreate new data at the path (didn't exist before)POSTvault kv put (new data)
readRead data from the pathGETvault kv get, vault read
updateModify existing data at the pathPOST / PUTvault kv put (existing data), vault write
deleteDelete data from the pathDELETEvault kv delete, vault delete
listList the keys at the pathLISTvault kv list, vault list
sudoPrivileged (root-like) operationsVariousvault auth enable, vault secrets enable
denyForbid 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.
  • Combined capabilitiesread + update on the same path is the most common combination for applications: reading config and updating certain values without being able to delete them.

Path Matching Rules

Policy paths don't have to be exact — Vault supports three matching patterns that determine access granularity:

PatternExampleMatchesDoesn't match
Exact pathsecret/data/apponly secret/data/appsecret/data/app/env, secret/data/apps
Prefix wildcard *secret/data/app/*all paths under the prefixsecret/data/app (the parent path itself)
Segment wildcard +secret/+/appsecret/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:

policy/kv-full.hcl
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.

Why KV v2 Needs the data/ Subpath

This 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:

  • Reading a secret: secret/data/<path> (capability read)
  • Listing secrets: secret/metadata/<path> (capability list)
  • Deleting versions: secret/metadata/<path> (capability delete)
  • Permanent destruction: 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.

policy/kv-v2-app.hcl
path "secret/data/app/*" {
  capabilities = ["create", "read", "update", "delete"]
}
 
path "secret/metadata/app/*" {
  capabilities = ["list", "delete"]
}
 
path "secret/destroy/app/*" {
  capabilities = ["update"]
}

Applying and Testing Policies

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

Write a policy from a file
vault policy write app-backend policy/app-backend.hcl
Output (example)
Success! Uploaded policy: app-backend

2. Viewing the list and contents of policies

List all policies
vault policy list
Output (example)
admin
app-backend
default
root

To see a policy's full contents:

Read a policy's contents
vault policy read app-backend

3. Creating a token that uses that policy

Create a token with the app-backend policy
vault token create -policy=app-backend
Output (example)
Key                  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:

Check a token's capabilities against a path
export VAULT_TOKEN=hvs.CAESIHK7... # token from the output above
vault token capabilities secret/data/app
vault token capabilities secret/data/production/db
Output (example)
read list
deny

The 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

Test reading a secret as app-backend
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:

Access denied error output (example)
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 denied

Summary Table: Operations vs Required Capabilities

For easier recall, here's a map of common operations you'll most often need:

CommandPath TouchedRequired Capability
vault kv get secret/appsecret/data/appread
vault kv put secret/app x=ysecret/data/appcreate (new) / update (existing)
vault kv delete secret/appsecret/data/appdelete
vault kv list secret/secret/metadata/list
vault kv metadata get secret/appsecret/metadata/appread
vault kv destroy -versions=1 secret/appsecret/destroy/appupdate
vault read database/creds/app-dbdatabase/creds/app-dbread
vault write pki_int/issue/app-cert ...pki_int/issue/app-certcreate, update

Combining Multiple Policies: Union Semantics

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.

Full Practice Scenario: Policies for Three User Types

Let's put it all together in a simple scenario. Suppose Vault is used by three groups with different needs:

GroupNeedPolicy
Backend applicationRead app config, get DB creds, issue certsapp-backend
DevelopersRead + write config in the staging env, list alldev-kv
Platform adminsManage Vault itselfadmin
policy/dev-kv.hcl
path "secret/data/staging/*" {
  capabilities = ["create", "read", "update", "delete", "list"]
}
 
path "secret/metadata/staging/*" {
  capabilities = ["list"]
}
policy/admin.hcl
path "sys/*" {
  capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
Apply all three policies
vault policy write app-backend policy/app-backend.hcl
vault policy write dev-kv policy/dev-kv.hcl
vault policy write admin policy/admin.hcl

Then create a token for each and test with vault token capabilities:

Verify each role's access
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-key

Expected output: the dev token can create read update delete list on the staging path, and deny on the production path.

Common Mistakes in Writing Policies

MistakeSymptomSolution
Forgetting the data/ subpath for kv-v2vault kv get fails even though the policy looks rightRemember: data at secret/data/, metadata at secret/metadata/
Prefix wildcard doesn't include the parent pathAccess to the path itself is deniedWrite two blocks: parent path + parent path /*
Giving list when read was intendedUsers can see secret names but not their contentsAdd the read capability if needed
deny placed in a policy the token doesn't carryThe blockade doesn't applyMake sure deny wins: put deny in a policy that's always attached
Missing capability (e.g. read without list)List operation failsCheck which path the command touches via vault token capabilities
Overly broad policy (* with ["read"])Blast radius grows if the token leaksNarrow it to specific paths and use least privilege
Forgetting to re-apply after editing the fileThe old policy is still activeRe-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.

Conclusion

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!

Learn Vault - Vault Policies (HCL & Fine-Grained Access Control) | Learn Secret Management with HashiCorp Vault