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/openbaocontainer 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 anrbpki-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"
$ 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_tokenis 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.accessoris a handle to the token that cannot be used to authenticate. It is what you record in your own logs, and what you pass tobao token revoke -accessorwhen you need to kill a specific session without ever having held its token.policiesandtoken_policiesboth listapp-readanddefault. The role granted one policy;defaultis attached to every token and cannot be removed. An access review that ignoresdefaultis incomplete.metadatacarriesrole_name, which is how an audit record made by this token can be traced back to the role that authorised it.orphan: truemeans 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: 1200is the twenty minutes fromtoken_ttl, in seconds, andrenewablesays 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
$ 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 appuserIllustrative 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.txtcontainstoken_policiesincludingapp-read, and non-zerotoken_ttlandtoken_max_ttlvalues. 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
policieslist is exactlyapp-readanddefault. 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 listwill show the difference immediately. grep -c 'REDACTED-TOKEN' login-auth.jsonreturns 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.txtreturns exactly 1. A count of 2 meanssecret_id_num_usesdid 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 awswill 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.
accessorfor revocation,policiesincludingdefault,orphanfor lifecycle independence,lease_durationfor the real lifetime. - Server-side constraints are the only ones that count.
secret_id_num_uses=1is 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.