Skip to main content
RunBook Academy

← All labs in Secrets, PKI & Certificates

Lab · advanced · ~60 min

Lab 22: Authenticate a workload with AppRole

B · Nested virtualisationC · Simulation

Objectives

  • Enable AppRole and bind a role to a policy with explicit token lifetimes
  • Distinguish the RoleID from the SecretID and explain why they are delivered separately
  • Log in and read every field of the returned auth block, including the lease and the policy set
  • Constrain a SecretID by use count and prove the constraint is enforced by the server

Objective

A human logs in with something they know and something they have. A process cannot do either. It has no memory between restarts, no fingers, and no way to be surprised by a phishing page. So every secret-manager deployment eventually confronts the same question: how does a program that has just started prove it is the program it claims to be, before it has been given anything?

AppRole is the classical answer for a server-based workload. It splits the credential in two. The RoleID says which role you are and is treated as a low-sensitivity identifier. The SecretID is the part that must stay secret, is issued separately, and can be constrained by lifetime, by use count and by source address. In this lab you build one, use it, and read the response field by field.

You will also do something a vendor tutorial will not: you will state plainly what AppRole does not solve. It moves the bootstrap problem rather than removing it, and the honest end of this lab is naming what does remove it.

Architecture

One OpenBao container, one KV version 2 mount, one policy, and one AppRole role bound to that policy. The workload in the story holds two strings and no secret data. It exchanges them for a token, and the token is what carries the policy set for the rest of its short life.

flowchart LR
    A["Deployment system\ndelivers RoleID"] --> C["Workload"]
    B["Trusted broker\ndelivers SecretID"] --> C
    C --> D["POST auth/approle/login"]
    D --> E["Token with app-read\nand default policies"]
    E --> F["GET kv/data/app/config"]

The two halves arrive by different routes on purpose. If one delivery channel is compromised, the attacker holds half a credential. The RoleID can sit in a configuration file or an environment variable; the SecretID should be short-lived, delivered as late as possible, and never written to the image.

Requirements

  • Docker with a working daemon and permission to run containers as your user.
  • OpenBao 2.6.x, pulled as the openbao/openbao container image. Nothing is installed on the host.
  • Roughly 60 minutes and about 200 MB of free disk in your home directory.
  • No out-of-band access requirement. This lab does not touch SSH, the firewall, the primary interface, or /etc/fstab. The container, the network and the lab directory all carry an rbpki- prefix.

Scenario

A payments reconciliation job runs on six virtual machines built from a golden image. It currently reads a database password from a file that configuration management writes at build time, which means the password is in the image, in the configuration repository, and in every backup of both. Your task is to replace that with an authentication step, and to be able to explain to the security reviewer exactly what an attacker gains by stealing each half of the new credential.

Tasks

Task 1 — Record the starting state and start a server

LAB="$HOME/rbpki-lab22"
# The secret manager writes its storage as root inside the container, so hand
# the tree back before removing it from the host.
if [ -d "$LAB" ]; then
  docker run --rm -v "$LAB:/w" alpine:3.22 \
    sh -c 'rm -rf /w/* /w/.[!.]* 2>/dev/null || true'
fi
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

docker ps -a --format '{{.Names}}' | sort > "$LAB/state.pre-lab"
docker network ls --format '{{.Name}}' | sort >> "$LAB/state.pre-lab"

docker network create rbpki-net-22
docker run -d --name rbpki-bao-22 --network rbpki-net-22 --cap-add=IPC_LOCK \
  -e BAO_DEV_ROOT_TOKEN_ID=rbpki-lab-root-not-a-real-token \
  -e BAO_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
  openbao/openbao:latest server -dev

sleep 8
docker inspect -f '{{.State.Status}}' rbpki-bao-22

The development server is chosen here because the subject is the authentication path, not the barrier. Everything in this lab works identically against a properly initialised server; only the first two commands would change.

Task 2 — Create the secret and the policy the role will carry

LAB="$HOME/rbpki-lab22"

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao secrets enable -path=kv -version=2 kv

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao kv put kv/app/config db_user=appuser db_password=lab-only-not-real

cat > "$LAB/app-read.hcl" <<'EOF'
path "kv/data/app/config" {
  capabilities = ["read"]
}
EOF
docker cp "$LAB/app-read.hcl" rbpki-bao-22:/tmp/app-read.hcl

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao policy write app-read /tmp/app-read.hcl

The policy grants one capability on one API path. Binding a role to a policy is only as useful as the policy, so this step deserves the same review as the role definition that follows.

Task 3 — Enable AppRole and define the role

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao auth enable approle

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao write auth/approle/role/app-role \
    token_policies=app-read \
    token_ttl=20m \
    token_max_ttl=1h

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao read auth/approle/role/app-role > "$HOME/rbpki-lab22/approle-role.txt" 2>&1
cat "$HOME/rbpki-lab22/approle-role.txt"

The first command reports Success! Enabled approle auth method at: approle/ and the second reports Success! Data written to: auth/approle/role/app-role.

Two of the three settings on that role are lifetime controls and they are not decoration. token_ttl is what a login actually gets, and twenty minutes means a token stolen from a process image is useless twenty minutes later unless the thief can also renew it. token_max_ttl is the ceiling that renewal can never cross, so even a well-behaved client must come back and authenticate again within the hour. Leaving both unset gives you the mount defaults, which are measured in days.

Task 4 — Read the RoleID and generate a SecretID

LAB="$HOME/rbpki-lab22"
umask 077

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao read -field=role_id auth/approle/role/app-role/role-id > "$LAB/role-id"

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao write -f -field=secret_id auth/approle/role/app-role/secret-id > "$LAB/secret-id"

chmod 600 "$LAB/role-id" "$LAB/secret-id"
printf 'role_id   : %s\n' "$(cat "$LAB/role-id")"
printf 'secret_id : %.8s... (redacted)\n' "$(cat "$LAB/secret-id")"

Notice the asymmetry in the two commands. The RoleID is read: it is a property of the role, it is stable, and reading it twice gives the same value. It is a UUID, of the shape e0e9a8d8-e631-a845-7fe3-69bfb9b528c3. The SecretID is written: every call mints a new one, and a role can have many valid SecretIDs at once, which is what allows a fleet to be issued distinct credentials from a single role definition.

Task 5 — Log in and capture the auth block

LAB="$HOME/rbpki-lab22"

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao write -format=json auth/approle/login \
    role_id="$(cat "$LAB/role-id")" \
    secret_id="$(cat "$LAB/secret-id")" > "$LAB/login-raw.json"

sed -E 's/(client_token": ")[^"]*/\1[REDACTED-TOKEN]/' "$LAB/login-raw.json" \
  > "$LAB/login-auth.json"
grep -E 'policies|lease_duration|renewable|orphan' "$LAB/login-auth.json"
Configuration changethe auth block returned by a successful AppRole login
$ docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
-e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
bao write -format=json auth/approle/login \
  role_id="$(cat "$LAB/role-id")" \
  secret_id="$(cat "$LAB/secret-id")"
"auth": {
  "client_token": "s.<REDACTED>",
  "accessor": "n5GSr33ga2aoj00X03Z3y4Qh",
  "policies": ["app-read", "default"],
  "token_policies": ["app-read", "default"],
  "metadata": {"role_name": "app-role"},
  "orphan": true,
  "lease_duration": 1200,
  "renewable": true
}

Illustrative output

Six fields are worth understanding, and each one answers an operational question.

  • client_token is the bearer credential. Anything holding this string is the workload as far as the server is concerned. It is the only field in the response that must never be logged.
  • accessor is a handle to the token that cannot be used to authenticate. It is what you record in your own logs, and what you pass to bao token revoke -accessor when you need to kill a specific session without ever having held its token.
  • policies and token_policies both list app-read and default. The role granted one policy; default is attached to every token and cannot be removed. An access review that ignores default is incomplete.
  • metadata carries role_name, which is how an audit record made by this token can be traced back to the role that authorised it.
  • orphan: true means the token has no parent. Revoking some other token will not cascade to this one, which is correct for a machine credential whose life should not depend on whoever created the role.
  • lease_duration: 1200 is the twenty minutes from token_ttl, in seconds, and renewable says the workload may extend it up to the role’s maximum.

Task 6 — Use the token, and confirm it carries the policy and nothing more

LAB="$HOME/rbpki-lab22"
APP_TOKEN=$(grep -o '"client_token": *"[^"]*"' "$LAB/login-raw.json" | head -1 | cut -d'"' -f4)

docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN="$APP_TOKEN" rbpki-bao-22 \
  bao kv get kv/app/config
Read-only / Safethe AppRole token reading the one path its policy allows
$ docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN="$APP_TOKEN" rbpki-bao-22 bao kv get kv/app/config
======= Data =======
Key            Value
---            -----
db_password    lab-only-not-real
db_user        appuser

Illustrative output

The workload now holds a database password it never stored. It obtained it by proving possession of two strings, and the token it used will stop working within the hour whatever happens next.

Task 7 — Write down where secret zero comes from

Before running anything else, answer this in secret-zero.md, in your own words:

cat > "$HOME/rbpki-lab22/secret-zero.md" <<'EOF'
# Secret zero for the reconciliation job

1. Who or what delivers the RoleID to a freshly built host, and where does it rest?
2. Who or what delivers the SecretID, how long is it valid, and how many times can it be used?
3. If an attacker reads the host's disk at rest, which halves do they get?
4. What is the smallest change that would stop the answer to 3 from being "both"?
EOF
cat "$HOME/rbpki-lab22/secret-zero.md"

This is the exercise, not a formality. A team that cannot answer question 3 has usually written both halves into the same configuration management run, which produces a credential pair sitting side by side in a repository, in the build artefacts, and in every host’s disk image. At that point AppRole has added a login step and improved nothing.

Task 8 — What actually removes the bootstrap secret

The class of answer that removes it is platform-attested workload identity: the workload proves who it is using a credential the platform issues and the secret manager independently verifies, so nothing long-lived is ever placed on the host by your pipeline.

  • Kubernetes authentication takes the pod’s own projected service account token, which the kubelet mints, rotates and binds to the pod, and validates it by calling the cluster’s TokenReview API. The secret manager is not trusting the token on its face; it is asking the cluster whether the token is still valid. The identity comes from the platform that scheduled the workload.
  • JWT or OIDC authentication against the cluster’s own issuer is the documented route where short-lived, audience-bound tokens are wanted for every client rather than a long-lived reviewer credential. It is also the pattern that CI systems use when a pipeline job assumes a cloud role without a stored key.
  • SPIFFE and its SVID documents generalise the same idea across platforms: an identity document issued to a workload by an agent that attested the workload against the node’s own runtime, with a lifetime measured in minutes.

Note the shared property. In every one of these the credential the workload presents was created by something that could observe what the workload is, and it expires quickly enough that stealing it is of limited use. Where that machinery does not exist, AppRole with a short, single-use, CIDR-bound SecretID delivered as late as possible is the pragmatic answer, and Task 9 measures one of those constraints.

Task 9 — Constrain a SecretID, and prove the constraint binds

LAB="$HOME/rbpki-lab22"

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao write auth/approle/role/app-once \
    token_policies=app-read \
    token_ttl=5m \
    secret_id_num_uses=1 \
    secret_id_ttl=10m

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao read -field=role_id auth/approle/role/app-once/role-id > "$LAB/once-role-id"

docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
  -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
  bao write -f -field=secret_id auth/approle/role/app-once/secret-id > "$LAB/once-secret-id"
chmod 600 "$LAB/once-role-id" "$LAB/once-secret-id"

Now log in twice with the same SecretID and record what happened each time:

LAB="$HOME/rbpki-lab22"
ONCE_ROLE=$(cat "$LAB/once-role-id")
ONCE_SECRET=$(cat "$LAB/once-secret-id")

{
  for attempt in 1 2; do
    echo "--- attempt $attempt ---"
    docker exec -e BAO_ADDR=http://127.0.0.1:8200 \
      -e BAO_TOKEN=rbpki-lab-root-not-a-real-token rbpki-bao-22 \
      bao write -field=accessor auth/approle/login \
        role_id="$ONCE_ROLE" secret_id="$ONCE_SECRET" > /dev/null 2>&1
    echo "exit=$?"
  done
} > "$LAB/secret-id-reuse.txt" 2>&1

cat "$LAB/secret-id-reuse.txt"

The first attempt records exit=0 and the second records a non-zero status. The SecretID was consumed by the first login and the server no longer has a record of it, so the second presentation is not a valid credential rather than a rejected one. Output is discarded deliberately here: the exit status is the assertion, and the response body of a successful login contains a live token that should not land in a file you are about to read on screen.

This single-use behaviour is the strongest constraint AppRole offers. It converts a stolen SecretID from a reusable credential into a race: if the attacker uses it, the legitimate workload fails to start and someone notices, and if the workload starts first, the stolen copy is already worthless.

Task 10 — Capture the deliverables

cd "$HOME/rbpki-lab22"
ls -l approle-role.txt login-auth.json secret-id-reuse.txt secret-zero.md
grep -c 'REDACTED-TOKEN' login-auth.json
grep -c 'exit=0' secret-id-reuse.txt

Validation

  • approle-role.txt contains token_policies including app-read, and non-zero token_ttl and token_max_ttl values. If both lifetimes are absent, the role inherited mount defaults and the short-lived-token claim in this lab does not hold for your instance.
  • The login in Task 5 produces a response whose policies list is exactly app-read and default. A longer list means another policy is being attached, most often through an identity group.
  • The read in Task 6 exits 0 and prints db_password. A 403 here means the role is bound to a policy name that does not exist; bao policy list will show the difference immediately.
  • grep -c 'REDACTED-TOKEN' login-auth.json returns 1. A count of 0 means the redaction did not match and the deliverable still holds a live token, which must be deleted rather than shared.
  • grep -c 'exit=0' secret-id-reuse.txt returns exactly 1. A count of 2 means secret_id_num_uses did not take effect and the role definition needs re-reading.

Expected Outcome

$HOME/rbpki-lab22/
├── app-read.hcl
├── approle-role.txt
├── login-auth.json
├── login-raw.json
├── once-role-id
├── once-secret-id
├── role-id
├── secret-id
├── secret-id-reuse.txt
├── secret-zero.md
└── state.pre-lab

You can now describe an AppRole login precisely: which half is an identifier and which half is a secret, what the returned token carries, how long it lives, and which constraints the server enforces at login time. More usefully, you can tell a reviewer what an attacker gets from each half and why the interesting question is not AppRole at all, it is where the SecretID came from.

Troubleshooting

The login is refused immediately after the role is created. Check that the SecretID was generated against the same role name as the RoleID. Copying one from app-role and the other from app-once produces a refusal that looks like a bad secret.

Both attempts in Task 9 report exit=0. The role was created without secret_id_num_uses, or a second SecretID was generated between the two attempts. Read the role back and confirm the value.

The token from Task 5 is refused when reading the secret. Confirm the client token was extracted from login-raw.json and not from the redacted login-auth.json, which by design no longer contains a usable token.

bao write -f reports that no data was supplied. The -f flag is what permits a write with an empty body, which is exactly what a SecretID request is. Without it the CLI refuses to send the request at all.

Cleanup

LAB="$HOME/rbpki-lab22"

# 1. Stop and forget the service and its network.
docker rm -f rbpki-bao-22 || true
docker network rm rbpki-net-22 || true

# 2. Compare the host against the Task 1 capture.
docker ps -a --format '{{.Names}}' | sort > "$LAB/state.post-lab"
docker network ls --format '{{.Name}}' | sort >> "$LAB/state.post-lab"
diff "$LAB/state.pre-lab" "$LAB/state.post-lab" || true

# 3. Remove the lab directory, including every credential file.
# The secret manager writes its storage as root inside the container, so hand
# the tree back before removing it from the host.
if [ -d "$LAB" ]; then
  docker run --rm -v "$LAB:/w" alpine:3.22 \
    sh -c 'rm -rf /w/* /w/.[!.]* 2>/dev/null || true'
fi
rm -rf "$LAB"

To confirm the host is as you found it, run docker ps -a --format '{{.Names}}' | grep -c rbpki- and docker network ls --format '{{.Name}}' | grep -c rbpki-; both must return 0. ls -d "$HOME/rbpki-lab22" must report that the directory no longer exists, and the diff in step 2 must print nothing.

Production notes

  • Deliver the two halves by different systems. RoleID in the image or the configuration; SecretID from a broker at start-up, wrapped, single-use, and bound to the CIDR the workload will call from.
  • Set both token lifetimes explicitly on every role. Inheriting mount defaults is how a machine credential quietly acquires a lifetime measured in days.
  • Record accessors, never tokens. An accessor lets you revoke or look up a session without ever handling a usable credential, and it is safe to write into your own application logs.
  • Cloud IAM authentication is not built into this binary. The plugins for AWS, Azure, GCP and other cloud providers were moved out of the core distribution and must be installed separately. A design that assumes bao auth enable aws will work on a stock binary will fail at the first step.
  • Revisit the decision when the platform changes. The moment the workload runs somewhere that can attest to it, whether a Kubernetes cluster or a CI system with an OIDC issuer, the AppRole machinery is the thing to retire.

What You Learned

  • The credential is split on purpose. RoleID identifies, SecretID authenticates, and separate delivery is what makes the split worth anything.
  • The auth block is an operational document. accessor for revocation, policies including default, orphan for lifecycle independence, lease_duration for the real lifetime.
  • Server-side constraints are the only ones that count. secret_id_num_uses=1 is enforced at login, and you proved it with two exit statuses rather than an assurance.
  • AppRole relocates secret zero. The workload stops holding the database password and starts holding the thing that fetches it.
  • Platform-attested identity is what closes the gap. Kubernetes TokenReview, JWT against the cluster issuer, or SPIFFE SVIDs replace a delivered secret with an attestation the platform makes.

Deliverables

  • · approle-role.txt - the role definition as the server stores it
  • · login-auth.json - the login response, with the client token redacted
  • · secret-id-reuse.txt - two login attempts against a single-use SecretID, with exit statuses
  • · secret-zero.md - your written answer to where the bootstrap credential comes from

Verification status

Last reviewed
2026-08-26
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.