Injecting Vault Secrets into Preview Deploys with OIDC
You want your preview environments to receive real credentials without any long-lived secret sitting on the runner — this is the complete OIDC-to-Vault workflow that mints a short-lived, PR-scoped credential per job, following the model in secrets injection for preview environments.
When to use this pattern
- You run HashiCorp Vault and want previews to fetch credentials with no static tokens in CI.
- You need each preview credential scoped to a preview-only path and expiring on a short TTL.
- You deploy previews to Kubernetes and inject secrets at container boot.
Prerequisites
Complete working example
# --- One-time Vault setup (run by an operator) ---
# 1. Trust GitHub's OIDC issuer.
vault write auth/jwt/config \
oidc_discovery_url="https://token.actions.githubusercontent.com" \
bound_issuer="https://token.actions.githubusercontent.com"
# 2. Read-only policy scoped to the preview path ONLY.
vault policy write preview-read - <<'HCL'
path "secret/data/preview/*" { capabilities = ["read"] }
HCL
# 3. PR-scoped role: only pull_request events on this repo may assume it, 30-min TTL.
vault write auth/jwt/role/preview \
role_type="jwt" user_claim="sub" \
bound_audiences="https://github.com/acme" \
bound_claims='{"repository":"acme/app","event_name":"pull_request"}' \
token_policies="preview-read" token_ttl="30m" token_max_ttl="30m"# --- .github/workflows/preview-deploy.yml (secrets portion) ---
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # REQUIRED to mint the OIDC token
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Fetch preview secrets from Vault via OIDC
uses: hashicorp/vault-action@v3
with:
url: ${{ secrets.VAULT_ADDR }}
method: jwt
role: preview # matches the role created above
exportEnv: true # values become env vars, auto-masked in logs
secrets: |
secret/data/preview/app DATABASE_URL | DATABASE_URL ;
secret/data/preview/app API_KEY | API_KEY ;
secret/data/preview/app REDIS_URL | REDIS_URL
- name: Inject secrets into the preview namespace at boot
env:
PR: ${{ github.event.pull_request.number }}
run: |
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config" && mkdir -p "$HOME/.kube"
# Create/patch the Secret the preview Deployment mounts as envFrom at startup.
kubectl -n "preview-${PR}" create secret generic app-secrets \
--from-literal=DATABASE_URL="$DATABASE_URL" \
--from-literal=API_KEY="$API_KEY" \
--from-literal=REDIS_URL="$REDIS_URL" \
--dry-run=client -o yaml | kubectl apply -f -Step-by-step walkthrough
Vault JWT config. vault write auth/jwt/config establishes trust in GitHub’s OIDC issuer. No shared secret is exchanged — Vault verifies token signatures against GitHub’s public keys.
The preview-read policy. It grants read on secret/data/preview/* and nothing else. This is the isolation guarantee: a token issued for a preview can never read secret/data/production/*.
The preview role. bound_claims restricts assumption to pull_request events on acme/app, so a push to main, a fork, or another repo cannot mint this credential. token_ttl=30m means the credential expires well within the job’s lifetime, leaving nothing durable to leak.
The OIDC exchange. hashicorp/vault-action requests the runner’s OIDC token (enabled by id-token: write), presents it to Vault, receives a 30-minute token, and fetches the listed secrets. exportEnv: true masks the values in logs automatically.
Boot-time injection. The secrets are written into a Kubernetes Secret that the preview Deployment consumes via envFrom at startup — never baked into the image. The image built earlier contains no credentials and remains safe to cache and share.
Verification
# 1. Prove production isolation: with a preview token, production reads are denied.
VAULT_TOKEN=$(cat "$HOME/.vault-token")
vault kv get secret/production/app # → permission denied
vault kv get secret/preview/app # → succeeds
# 2. Prove the credential is short-lived.
vault token lookup -format=json | jq '.data.ttl' # → ≤ 1800 (30 min)
# 3. Prove no raw value leaked: scan the job log for a known prefix.
grep -c 'postgresql://' preview-deploy.log || echo "no raw DB URL in logs"Expected: production access denied, TTL at most 30 minutes, and no raw secret value in the logs.
Common pitfalls
- Missing
id-token: write. Without it the OIDC token cannot be minted and the action fails with a 403 requesting the token. Add the permission at the job level. - Over-broad
bound_claims. Omittingevent_namelets a push or a fork assume the preview role. Scope it topull_requestand the specific repository. - Echoing secrets.
echo "$DATABASE_URL"defeats masking. Reference secret env vars only as arguments to the consuming command, and add a log scanner as a backstop, as covered in the parent guide.
Related
- Secrets Injection for Preview Environments — the parent guide on scoping, masking, and TTL.
- Synchronizing Environment Variables Across Stages — the non-secret config companion, which also uses Vault OIDC.
- Ephemeral Preview URL Management and Routing — exposing the app that consumes these secrets.
- Preview Environments & Environment Parity — the section overview.