Remove permanent API keys from the repository and repository secrets, and let the pipeline fetch temporary credentials directly from Vault on every run — via hashicorp/vault-action in GitHub Actions or the native secrets keyword in GitLab CI.

After covering in episode 17 how Vault integrates with Kubernetes — from the Kubernetes Auth Method, to the Vault Agent Sidecar Injector, to the Vault Secrets Operator — this episode covers another equally important battlefront: CI/CD pipelines.
Imagine a very common industry scenario: a DevOps team stores production API keys in GitHub Secrets or GitLab CI/CD Variables, then the pipeline exports that key to an environment variable on every build. This practice feels convenient, but let's dissect the risks. Repository secrets are long-lived secrets — they never expire, never rotate automatically, and their values are exactly the same today as six months from now. Just one incident — a developer accidentally running a pipeline on a fork or a public runner, or one workflow echoing a secret variable to the logs — and that production credential is permanently leaked and must be rotated manually, which often never happens.
In the real world, attacks against CI/CD have become one of the primary vectors of software supply chain hacking. According to various security reports, CI/CD systems are attackers' favorite targets because they concentrate many credentials in one place. So the principle we build in this episode is: CI/CD pipelines must not store long-lived credentials; they may only borrow them for a short time. The pipeline logs into Vault on every run, receives a short-TTL token, fetches the secrets it needs, then "forgets" everything when done.
Before diving into implementation, let's understand why this old pattern is fundamentally flawed. The key is the difference between static and dynamic credentials:
| Aspect | Repository Secrets (Static) | Vault (Dynamic/Short-Lived) |
|---|---|---|
| Lifetime | Permanent until manually deleted | Minutes to hours (TTL) |
| Rotation | Manual, often forgotten | Automatic; every login gets new credentials |
| If leaked | Permanently dangerous, needs emergency rotation | Dangerous only for the TTL; automatically invalid afterward |
| Audit trail | No trace of who used it and when | Every access recorded in Vault's audit log |
| Scoping | One secret used by all jobs | Token and policies can be scoped per job/role |
The analogy is using a permanent warehouse key copied to all employees, versus a hotel access card that automatically dies when the stay ends. A permanent key falling into the wrong hands means your warehouse is no longer safe; a lost hotel card automatically becomes useless after a few hours.
With Vault, the correct pattern is:
Pipeline starts
│
▼
Vault funds the pipeline's identity (AppRole / OIDC / JWT)
│ → Vault issues a short-TTL token
▼
Pipeline reads the secrets its policy allows (KV / DB / PKI)
│ → Secrets have their own lease & TTL
▼
Pipeline finishes → token & lease expire on their ownFor GitHub Actions, our main tool is hashicorp/vault-action — HashiCorp's official action that handles logging into Vault, reading secrets, and placing them into the job's environment variables. This action handles the tedious details: renewing the token as the TTL approaches, and cleaning up the token at the end of the job.
There are two commonly used authentication methods: AppRole (easy, fits self-hosted runners) and OIDC (no static credentials at all, fits GitHub-hosted runners). We'll cover both.
AppRole is the machine-to-machine mechanism we know from episode 10. For CI, we create a dedicated role that only has minimal policies — the least privilege principle. First, write a policy that only allows reading the data the pipeline needs:
path "kv/data/ci/*" {
capabilities = ["read", "list"]
}
path "database/creds/deploy-reader" {
capabilities = ["read"]
}Note: this policy only reads, it doesn't write. The pipeline must not have access to modify data in Vault. After that, register the policy and create the AppRole role:
vault policy write ci-app ci-policy.hcl
vault write auth/approle/role/ci-app \
token_policies="ci-app" \
token_period="30m" \
token_ttl="10m" \
token_max_ttl="1h" \
secret_id_ttl="5m" \
secret_id_num_uses="1" \
bound_cidr_list="10.0.0.0/8"
vault read -field=role_id auth/approle/role/ci-app/role-id
vault write -f -field=secret_id auth/approle/role/ci-app/secret-idLet's break down the important parameters:
token_period="30m" — makes the token periodic; vault-action can renew the token while the pipeline runs, so jobs lasting longer than 10 minutes don't break midway.token_ttl="10m" / token_max_ttl="1h" — the first token is valid for 10 minutes, and even with periodic renewal it never exceeds the 1-hour cap. This ensures the pipeline only "borrows" access in the short term.secret_id_ttl="5m" and secret_id_num_uses="1" — the generated SecretID is only valid for 5 minutes and only for a single login. This is the safest pattern: you push a fresh SecretID to the runner each time a pipeline is about to run, instead of storing it permanently.bound_cidr_list — limits the source IP addresses allowed to log in. For self-hosted runners on an internal network, this is good defense.Note
The secret_id_num_uses="1" pattern requires you to push a fresh SecretID to each pipeline (for example via the Vault API to the runner, or calling vault write -f auth/approle/role/ci-app/secret-id in a step before login). If you choose to store a static SecretID in GitHub Secrets, set secret_id_ttl="0" and secret_id_num_uses="0" so it never expires — but remember, that recreates a long-lived credential that must be rotated manually. For the best result, use OIDC covered below.
Now let's write a deploy workflow that fetches secrets from Vault on every run, not from repository secrets:
name: Deploy Application
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Import secrets from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.example.com:8200
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets: |
kv/data/ci/app DB_USERNAME | DB_USERNAME ;
kv/data/ci/app DB_PASSWORD | DB_PASSWORD ;
database/creds/deploy-reader POSTGRES_PASSWORD | password
- name: Build application
run: |
npm ci
npm run build
- name: Deploy to server
run: |
./scripts/deploy.sh "$DB_USERNAME" "$DB_PASSWORD"Several important things about the workflow above:
secrets: syntax of vault-action is <vault_path> <target_env_name> | <secret_key>, separated by ;. Note the KV v2 path uses kv/data/ci/app — the data/ segment is mandatory, this is the most common mistake (covered in the pitfalls section).database/creds/deploy-reader POSTGRES_PASSWORD | password — here we fetch dynamic credentials from the database secrets engine (episode 5). Every pipeline run gets new database username/password that automatically expires per its TTL. This is the pinnacle of the "short-lived" philosophy.${{ secrets.VAULT_ROLE_ID }} and ${{ secrets.VAULT_SECRET_ID }} — only two bootstrap credentials stored in GitHub Secrets. Even those should be minimal and rotated periodically.Warning
Never write echo "$DB_PASSWORD" or print secrets to the logs just to "debug." GitHub Actions logs are persistent and readable by anyone with repository access — that's the same as publishing the secret. If you must verify a value exists, print its length only: echo "password length: ${#DB_PASSWORD}".
The AppRole pattern above is already far better than repository secrets, but it still leaves two bootstrap credentials in GitHub Secrets. The cleanest solution is OIDC: GitHub Actions issues an ID token containing claims like repository and ref, then Vault validates that token. No RoleID, no SecretID, nothing to store.
First, enable JWT auth in Vault and create a role bound to the specific repo:
vault auth enable jwt
vault write auth/jwt/role/gha-deploy \
bound_issuer="https://token.actions.githubusercontent.com" \
bound_audiences="https://vault.example.com" \
user_claim="sub" \
token_policies="ci-app" \
token_ttl="10m" \
claim_mappings=repository=repositoryThen in the workflow, use method: oidc and allow the id-token: write permission:
name: Deploy Application (OIDC)
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Import secrets from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.example.com:8200
method: oidc
role: gha-deploy
secrets: |
kv/data/ci/app DB_PASSWORD | DB_PASSWORD
- name: Build application
run: npm run buildThe amazing thing about this pattern: your repository stores not a single Vault credential. Vault trusts the token issued by GitHub because the token is signed and its claims (repo, branch) are checked against the role. Credential changes? None. Rotation? Unnecessary. This is the current industry standard for CI/CD integration with a secret manager.
Tip
vault-action also supports namespace, tlsSkipVerify (never use it in production!), and exportToken: true to export VAULT_TOKEN to the job if other tools need direct Vault access. Keep the safe defaults: url is always HTTPS, and include role when using method: oidc so Vault knows which aud claim to validate.
secrets: KeywordGitLab offers deeper integration because GitLab already connects directly to Vault as a CI/CD secret resolver. No external action — the GitLab runner logs into Vault using the job JWT, fetches the declared secrets, and injects them into the job environment.
The GitLab runner sends CI_JOB_JWT_V2 to Vault, and Vault validates it via the JWT auth method. The role must be bound to a specific GitLab project:
vault auth enable jwt
vault write auth/jwt/role/gitlab-ci \
bound_issuer="https://gitlab.example.com" \
bound_audiences="https://gitlab.example.com" \
user_claim="sub" \
bound_subject="project_path:myorg/myapp:*" \
token_policies="ci-app" \
token_ttl="10m"Brief explanation:
bound_issuer and bound_audiences — GitLab issues the JWT with the GitLab instance URL as issuer and audience.bound_subject — binds the role to a specific project via project_path:group/project. The trailing * allows all branches of that project; you can narrow it to ref:main if needed.token_policies="ci-app" — uses the same policy as the GitHub example above.One administrative step you must not miss: tell GitLab where Vault is and which role to use. This is done at Project → Settings → CI/CD → Variables → Vault server:
| Field | Example Value |
|---|---|
| Vault server URL | https://vault.example.com:8200 |
| Vault auth role | gitlab-ci |
| Vault namespace | empty (for Vault Open Source) |
After that, write the .gitlab-ci.yml with the secrets: keyword:
stages:
- build
- deploy
secrets:
DB_PASSWORD:
vault: kv/data/ci/app DB_PASSWORD@kv
API_KEY:
vault: kv/data/ci/app API_KEY@kv
file: false
POSTGRES_DYNAMIC:
vault: database/creds/deploy-reader password@database
file: true
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
deploy:
stage: deploy
image: alpine:3
script:
- apk add --no-cache postgresql-client
- psql "postgres://app:$POSTGRES_DYNAMIC@db.example.com/appdb" -c "SELECT 1"Breaking down the vault: keyword syntax:
vault: <secret_path> <secret_field>@<mount_point>. For KV v2, the full path is kv/data/ci/app, the field is DB_PASSWORD, and the mount is kv.database/creds/deploy-reader password@database — fetches the password field from dynamic database credentials (mount database). GitLab gets a new lease every pipeline, and Vault revokes it after the TTL.file: true — writes the secret to a temporary file ($DB_PASSWORD_FILE-style) instead of an environment variable. This prevents the secret from appearing on the job variables page and reduces the risk of being printed to the logs.Note
GitLab also supports id_tokens: to create an OIDC token with a custom aud claim, useful for connecting GitLab to Vault with a more explicit mechanism (similar to GitHub's OIDC) or to other systems accepting OIDC. Native secrets: is still the simplest because it needs no extra steps.
Both platforms solve the same problem with different philosophies. The following table summarizes the comparison:
| Aspect | GitHub Actions | GitLab CI |
|---|---|---|
| Mechanism | hashicorp/vault-action step | Native secrets: keyword |
| Default auth | AppRole or OIDC (method: oidc) | Job JWT (CI_JOB_JWT_V2 → auth/jwt/login) |
| Server config | Per step in the workflow (url, method) | Project settings → Vault server |
| Inject into job | Environment variables from action outputs | Environment variable or file (file: true) |
| Dynamic secrets | Fetch via path in secrets: | Fetch via path in vault: |
| Bootstrap credentials | RoleID/SecretID (or nothing via OIDC) | None — only needs URL + role |
| Token renewal | Automatic via vault-action | Handled by GitLab/Vault JWT login |
In short: GitLab is more "native" and zero-config from the pipeline side; GitHub Actions is more flexible because you fully control the login process inside the workflow.
Finally, let's talk about the traps that most often haunt teams new to Vault integration:
| Mistake | Symptom | Solution |
|---|---|---|
KV v2 path without the data/ segment | Permission denied or secret not found | Use kv/data/ci/app, not kv/ci/app |
| RoleID/SecretID committed to the repo | Credentials permanently leaked into git history | Store in Secrets/Variables; rotate if ever committed |
| Token TTL too short | Long job fails midway | Use token_period + renewal, or a sufficient token_max_ttl |
| Eternal service token used by the pipeline | Back to the long-lived secrets problem | Always use AppRole/JWT with a short TTL |
| Secret printed to the logs | Leaked into the persistent log viewer | Masking, file: true, don't echo |
bound_cidr_list on GitHub-hosted runners | Login denied because the runner IP changes | Use OIDC, or a dedicated CIDR for self-hosted runners |
Forgetting id-token: write (OIDC) | vault-action error 401 | Add the id-token: write permission in the workflow |
| One role for all pipelines | Compromise of one project = access to everything | One role per project/pipeline with minimal policies |
Important
Even though the pipeline only "borrows" credentials for a short time, the least privilege principle is still mandatory. CI roles may only read the paths that job needs — never give a pipeline a * policy on kv/*. Because if a runner or workflow is compromised, that boundary is your last line of defense.
In this episode 18 we've covered how to stop the habit of storing permanent API keys in repository secrets and replace it with temporary credentials fetched directly from Vault when the pipeline runs. We saw complete implementations with hashicorp/vault-action in GitHub Actions — both via AppRole and via OIDC with no static credentials — as well as the native secrets: keyword integration in GitLab CI that injects secrets from Vault into jobs automatically.
The key points to take home:
vault-action manages token login, renewal, and revocation; OIDC eliminates bootstrap credentials entirely.secrets: keyword + JWT auth role makes integration native with no extra action.data/, token TTLs must be sufficient for the job duration, and secrets must never be printed to the logs.In episode 19, we level up: Vault integration with Infrastructure as Code (IaC) using the Terraform Vault Provider and the Ansible lookup plugin — how your infrastructure can issue dynamic database credentials or PKI certificates directly at provisioning time, and how to avoid the trap of secrets leaking into tfstate and logs. Keep your enthusiasm up!