Why this lesson exists
People log in with a password or SSO. A CI job, a cron job or a service on a VM cannot type a password - and if you give it a long-lived token in a CI variable, you are back to the static secret Vault was supposed to replace. AppRole is Vault's login for machines: two pieces, delivered separately, one of them short-lived and single-use. This lesson is how it works and how to deliver the pieces without either leaking.
What you need to know already: tokens, TTLs and orphans (the tokens lesson); policies and testing them with a token (the policies lesson); CI variables and secrets (25.7).
The words you need first
- AppRole - an auth method where a machine logs in with a role ID and a secret ID.
- Role ID - identifies the role (like a username). Not secret on its own; often baked into the deployment configuration.
- Secret ID - the password half. Can be limited in lifetime (
secret_id_ttl), in number of uses (secret_id_num_uses) and in where it may be used from (secret_id_bound_cidrs). - Secret zero - the first credential a machine needs to get every other one. Every design has one; the question is how short-lived and how narrowly delivered it is.
- Response wrapping - Vault puts a response in a single-use wrapping token with a short TTL; whoever unwraps it first gets the content, and nobody else ever can.
- Trusted orchestrator - the system that is allowed to fetch secret IDs (the CI platform, a deploy tool) and hands them, wrapped, to the job that uses them.
A role for a CI job
$ vault auth enable approle
Success! Enabled approle auth method at: approle/
$ vault write auth/approle/role/ci-deploy token_policies=ci-deploy token_ttl=10m token_max_ttl=15m secret_id_ttl=1h secret_id_num_uses=1 secret_id_bound_cidrs=10.0.5.0/24
Success! Data written to: auth/approle/role/ci-deploy
Read it back - the role is the whole contract between Vault and the job:
$ vault read auth/approle/role/ci-deploy
Key Value
--- -----
bind_secret_id true
local_secret_ids false
max_ttl 15m
period 0s
policies [ci-deploy]
secret_id_bound_cidrs [10.0.5.0/24]
secret_id_num_uses 1
secret_id_ttl 1h
token_bound_cidrs []
token_explicit_max_ttl 0s
token_max_ttl 15m
token_no_default_policy false
token_num_uses 0
token_period 0s
token_policies [ci-deploy]
token_ttl 10m
token_type default
ttl 10m
- token_policies / token_ttl / token_max_ttl - what the login gets: the
ci-deploypolicy for 10 minutes, 15 at most. A pipeline run does not need a 32-day token. - secret_id_ttl=1h, secret_id_num_uses=1 - each secret ID works once, within an hour. A secret ID stolen from a finished job's log is useless.
- secret_id_bound_cidrs - the secret ID only works from the CI runners' network.
token_bound_cidrsdoes the same for the token it produces. - bind_secret_id=true - a secret ID is required. Turning it off makes the role ID alone enough - which makes the role ID a password; avoid.
(max_ttl, period, policies, ttl are the old names of the token_* fields; Vault shows both.)
The two halves
$ vault read auth/approle/role/ci-deploy/role-id
Key Value
--- -----
role_id 65c26ebb-4844-5f3b-af7e-54e3f3a4c447
$ vault write -f auth/approle/role/ci-deploy/secret-id
Key Value
--- -----
secret_id 7084e0fa-db56-bb55-f4dd-fb66e3752d7c
secret_id_accessor 8c55aa7c-179c-8d7f-3bf5-1f5960d73e65
secret_id_num_uses 1
secret_id_ttl 1h
-f (force) because the request has no data. The secret ID accessor, like a token's, lets you look up and destroy a secret ID without knowing it: vault list auth/approle/role/ci-deploy/secret-id lists accessors.
Logging in
The login is a write to auth/approle/login - there is no vault login -method=approle in the CLI, because machines use the API:
$ vault write auth/approle/login role_id=$RID secret_id=$SID
Key Value
--- -----
token hvs.CAESIh1Ux9mWvfaUz3jDvjmfJVhzSuPJSuf3Wn8TaaGh4KHGh2cy4O4xZ40g4YDYSPLQxOsYns8oHRpDq0BFc
token_accessor 9Fx1xI4JNU65baruS4InFfTz
token_duration 10m
token_renewable true
token_policies ["ci-deploy" "default"]
identity_policies []
policies ["ci-deploy" "default"]
token_meta_role_name ci-deploy
The same request over HTTP, as a pipeline step would send it:
# an illustration (no ▶)
curl -s --request POST --data "{\"role_id\":\"$RID\",\"secret_id\":\"$SID\"}" $VAULT_ADDR/v1/auth/approle/login | jq -r .auth.client_token
The errors are deliberately vague: a wrong role ID, a wrong, expired or used-up secret ID all give the same answer, so an attacker learns nothing:
$ vault write auth/approle/login role_id=nope secret_id=nope
Error writing data to auth/approle/login: Error making API request.
URL: PUT https://127.0.0.1:8200/v1/auth/approle/login
Code: 400. Errors:
* invalid role or secret ID
Only the CIDR check says what it is - because by then the caller has a valid secret ID:
* source address "127.0.0.1" unauthorized through CIDR restrictions on the secret ID
Delivering the secret ID: response wrapping
The job needs a fresh secret ID each run, and whoever passes it along must not be able to use it. Response wrapping solves both: ask for the secret ID wrapped.
$ vault write -wrap-ttl=120s -f auth/approle/role/ci-deploy/secret-id
Key Value
--- -----
wrapping_token: hvs.CAESIpfHbp4pDrsFrFhQzVknndh6QohXAENvcgEFvKGh4KHGh2cy4cJDPbDVS6xC2rii4u2mPhU1djiDeQl9G
wrapping_accessor: LgPu54LAJcE2KJCfiPNUoy5J
wrapping_token_ttl: 2m
wrapping_token_creation_time: 2026-09-22 20:00:03.802908038 +0000 UTC
wrapping_token_creation_path: auth/approle/role/ci-deploy/secret-id
The secret ID is not in that output - it is inside Vault, behind a token that:
- lives 2 minutes;
- can be unwrapped once:
vault unwrap <token>returns the secret ID, and a second attempt fails with* wrapping token is not valid or does not exist; - shows where it came from:
vault write sys/wrapping/lookup token=...returns thecreation_path. The job checks it isauth/approle/role/ci-deploy/secret-idbefore using it, so nobody can slip it a different wrapped secret.
If the job's unwrap fails, someone else unwrapped it first: the secret ID was intercepted, and that is an incident, not a retry.
The trusted orchestrator pattern
CI platform (trusted orchestrator) pipeline job
has a token allowed to create secret IDs knows the ROLE ID (pipeline config)
for role ci-deploy - and nothing else
| ^
| vault write -wrap-ttl=120s -f .../secret-id |
'---- wrapping token (useless in 2 min) ------------>|
vault unwrap -> secret ID
auth/approle/login -> token (10m)
read what ci-deploy allows
Each half alone is not enough: the role ID sits in config, the secret ID arrives wrapped, for one run, from the CI runners' network only. The orchestrator's own token needs a policy like:
path "auth/approle/role/ci-deploy/secret-id" {
capabilities = ["update"]
min_wrapping_ttl = "1s"
max_wrapping_ttl = "300s"
}
min_wrapping_ttl forces it to ask for wrapped responses only: it cannot even see the secret IDs it creates.
AppRole or OIDC?
Modern build platforms (GitLab, GitHub, the cloud providers' own pipeline services) can give each job a signed OIDC token that says which repository, branch and pipeline it is. Vault's JWT/OIDC auth method verifies that token directly: no secret ID to deliver at all. Prefer that when the CI platform supports it; AppRole remains the tool for VMs, cron jobs, and platforms without workload identity - and it is what the Vault Agent lesson uses.
In an interview: "How does a build job get secrets from Vault without a long-lived token in a pipeline variable?" - an AppRole (or the JWT auth method with the platform's OIDC token): the role ID is in the pipeline config, the trusted build platform fetches a single-use, short-lived secret ID with -wrap-ttl, the job unwraps it, logs in for a 10-minute token with a narrow policy, and the secret ID is worthless afterwards.
What you can now do
- Create an AppRole with token TTLs, secret ID limits and CIDR bindings, and read it back.
- Fetch the role ID and secret IDs, and log in through
auth/approle/login. - Wrap a secret ID, check where a wrapping token came from, unwrap it once.
- Explain the trusted orchestrator pattern and when OIDC replaces AppRole.