Tutorial  on  Security

OpenBao 2.6: Discovering secrets with AppRole and least-priviledge policies.

In this tutorial we will learn how to use the new scan feature in OpenBao 2.6. It enables apps to dynamically discover secrets scoped to their namespace.

In particular, we will cover how to:

  • Carve out a dedicated, least-privilege area of the secrets store for one application
  • Write a least-priviledge policy that grants read and scan access to the required secrets
  • Create a machine identity with AppRole for our application
  • Use OpenBao 2.6's scan command to recursively fetch every secret the app is allowed to see

The environment

The playground for this tutorial boots with OpenBao already installed and running in dev mode. This means you can jump right into action!

Important

⚠️ Dev mode provides exactly what we need here: an ephemeral learning environment. It skips storage configuration, TLS, and the unseal process. Never run it that way in production!

Start the playground and confirm OpenBao is up and running:

bao status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         2.6.0
...

Let's log in using the iximiuz root token, so we can further configure OpenBao for this tutorial:

bao login iximiuz

The learning objective

Our goal is to stand up a small representative service, and it needs two things from OpenBao at startup:

  • Database credentials, to connect to its own database
  • An API key and webhook secret, to talk to an external service

The service should be able to read exactly those secrets and nothing else.

Furthermore, it should authenticate as an application, i.e., not as a human operator, but as an application acting in its own scope.

Give the app its own secrets area

It is a best-practice to give each team or application their own key-value (KV) mount rather than piling everything into a shared secret/ path. It keeps policies simple (one prefix per mount) and makes the mount itself a natural access boundary.

Create a dedicated KV v2 mount for the app:

bao secrets enable -path=myapp kv-v2

Then, write the required secrets:

bao kv put -mount=myapp db/creds username=app_svc password=s3cr3t-db-pw
bao kv put -mount=myapp external-api/api-key key=h1dd3n-api-key
bao kv put -mount=myapp external-api/webhook-secret value=unhackable

Write a least-privilege policy

Policies in OpenBao are deny by default. This means an empty policy grants nothing. In order to craft a least-priviledge token, we need to state exactly the permissions our token needs.

For the KV v2 engine, reading a secrets value and listing a secrets name, are exposed as two different paths: secret values live under data/, and both list and the new scan operate on metadata/.

Let's create the policy file:

cat > myapp-policy.hcl <<'EOF'
path "myapp/data/*" {
  capabilities = ["read"]
}

path "myapp/metadata/*" {
  capabilities = ["list", "scan"]
}
EOF

and write it to OpenBao:

bao policy write myapp-policy myapp-policy.hcl

The machine identity: AppRole

Humans log in to OpenBao with a username, password and MFA. A piece of software needs a different way to authenticate itself. The AppRole auth method is for this.

It enables machines or apps to authenticate with OpenBao-defined roles. The request states both the role_id (which identity is this?) and a secret_id (prove it), and trades them for a token. This is the app's login flow.

Enable the AppRole feature:

bao auth enable approle

Then create a role, bound to the policy we created before:

bao write auth/approle/role/myapp \
    token_policies="myapp-policy" \
    token_ttl=15m \
    token_max_ttl=2h \
    secret_id_ttl=2h
Note

💡 In production you'd also tune secret_id_bound_cidrs and secret_id_num_uses to constrain where and how many times a secret_id can be redeemed.

Let's fetch the role_id and secret_id, and store them for our app to pick up:

mkdir -p ~/myapp

bao read -field=role_id auth/approle/role/myapp/role-id > ~/myapp/role_id
bao write -f -field=secret_id auth/approle/role/myapp/secret-id > ~/myapp/secret_id
chmod 600 ~/myapp/secret_id

Log in as the app

Forget you're an administrator for a moment. This is what the app itself does at startup:

ROLE_ID="$(cat ~/myapp/role_id)"
SECRET_ID="$(cat ~/myapp/secret_id)"

bao write auth/approle/login role_id="${ROLE_ID}" secret_id="${SECRET_ID}"
Key                     Value
---                     -----
token                   s.xxxxxxxxxxxxxxxxxxxxxxxx
token_accessor          ...
token_duration          15m
token_renewable         true
token_policies          ["default" "myapp-policy"]

As we can see the token is tied to both the default and our custom myapp-policy policy.

Let's capture the token so we can execute the new few commands as the app:

APP_TOKEN="$(bao write -field=token auth/approle/login role_id="${ROLE_ID}" secret_id="${SECRET_ID}")"

Testing the limits

If we try to use the app's token to touch something outside its scope, for example the default secret/ mount:

BAO_TOKEN="${APP_TOKEN}" bao kv list -mount=secret ""
Error making API request.
...
Code: 403. Errors:
* preflight capability check returned 403, please ensure client's policies grant access to path "secret/"

The access is denied! As we would expect.

list vs scan

Now look inside the app's own area:

BAO_TOKEN="${APP_TOKEN}" bao kv list -mount=myapp ""
Keys
----
db/
external-api/

list only shows what's in the stated folder: two folders. Let's compare this to scan:

BAO_TOKEN="${APP_TOKEN}" bao kv scan -mount=myapp ""
Keys
----
db/creds
external-api/api-key
external-api/webhook-secret

bao scan (and its bao kv scan sibling) is a new command, which was shipped in OpenBao 2.6.0. It walks the whole prefix recursively in a single call instead of making you list each folder by hand.

Put it together: Our app's start-up script

Let's write a shell script that demonstrates how an app (of any language) would use this to load secrets during start-up:

cat > ~/myapp/fetch-secrets.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

ROLE_ID="$(cat ~/myapp/role_id)"
SECRET_ID="$(cat ~/myapp/secret_id)"

TOKEN="$(bao write -field=token auth/approle/login \
  role_id="${ROLE_ID}" secret_id="${SECRET_ID}")"

for key in $(BAO_TOKEN="${TOKEN}" bao kv scan -mount=myapp -format=json "" | jq -r '.[]'); do
  echo "=== ${key} ==="
  BAO_TOKEN="${TOKEN}" bao kv get -mount=myapp -format=json "${key}" | jq '.data.data'
done
EOF

chmod +x ~/myapp/fetch-secrets.sh

Run it:

~/myapp/fetch-secrets.sh
=== db/creds ===
{
  "password": "s3cr3t-db-pw",
  "username": "app_svc"
}
=== external-api/api-key ===
{
  "key": "abc123xyz"
}
=== external-api/webhook-secret ===
{
  "value": "whsec_000111"
}

No hardcoded credentials! No overly-broad access! Using a dedicated app identity that enables us to manage secrets and their life-cycle comfortably outside of the app.

A note on cleanup and TTLs

The token you captured in APP_TOKEN expires on its own (token_ttl=15m). If you wanted to cut the app off immediately instead of waiting execute:

bao token revoke "${APP_TOKEN}"

Finally, you can verify that the token was revoked:

BAO_TOKEN="${APP_TOKEN}" bao token lookup

This should return HTTP 403, if the token was revoked.

Congratulations! 🎉

You have successfully bootstrapped secrets for a small application using modern best practices such a machine identity and least-priviledge policies!

What's next?

Enterprise for OpenBao

Learn more about Enterprise for OpenBao which includes Enterprise Support.

Or talk to us, if you are curious about a migration assessment.

References

To dive deeper into the concepts covered in this tutorial, check out the resources below.

About the Author

Fabian Kammel

Fabian Kammel

Principal Security Consultant at ControlPlane - focusing on cloud native and container security & post-quantum cryptography.

Find this author online

Writes about

ci-cdkubernetessecurity

Frequently covers

#gitops#openbao#flux#flux-cd#helm