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 -
The Preview Role Is Not a Weaker Production Role The production role reads production secret paths with a one-hour lease. The preview role reads only preview paths with a fifteen-minute lease, and is bound to the pull-request subject claim. They are separate roles rather than one role with different parameters. production role preview role paths secret/data/production/* secret/data/preview/* only lease 1 hour 15 minutes bound to ref:refs/heads/main pull_request subject claim

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.

The Lease Expires Long Before the Environment Does A token is issued at deploy time and used within the first minute. It expires fifteen minutes later while the preview environment continues to exist for days. Anything that needs a secret after that must request a fresh one, which is what keeps a long-lived environment from holding a long-lived credential. used valid expired — the environment is still running, the credential is not deploy +15 min +3 days, pull request still open If something in the running preview needs a secret later, it must request its own — which is a design signal worth noticing.

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.

Do Not Write Secrets to Disk on the Runner Writing fetched secrets to a dotenv file leaves them on the runner's disk, in the workspace, where a later step or an uploaded artifact can expose them. Passing them directly into the process environment of the step that needs them leaves nothing behind. written to .env on the runner survives into later steps and any artifact that uploads the workspace passed to the process directly scoped to one step, nothing on disk, nothing to forget to delete Artifact uploads that glob the workspace are the usual way a dotenv file becomes a permanently stored secret.

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. Omitting event_name lets a push or a fork assume the preview role. Scope it to pull_request and 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.

Why the preview role must be a separate role

The tempting simplification is one Vault role for all deploys, with the path it can read varying by a parameter. It saves a policy and creates a hole, and the hole is not subtle: a pull-request run is untrusted code executing with whatever identity the workflow can obtain. If that identity can reach production paths under any circumstances, then a contributor who can open a pull request can, with sufficient creativity, read production secrets.

Separate roles bound to separate subject claims remove the possibility rather than relying on the parameter being passed correctly. The preview role’s policy names only preview paths; there is no argument that makes it return a production secret. That property survives a mistake in the workflow file, which a parameterised role does not.

Lease duration is the other half

Path scoping bounds what a leaked credential can read; lease duration bounds how long it can read it. Fifteen minutes is a good default for a deploy because it comfortably exceeds a deploy’s duration and expires long before anyone could exfiltrate, analyse and use the credential from elsewhere. The instinct to raise it — because a slow deploy once failed near the boundary — should be resisted in favour of finding out why the deploy was slow.

Where a running preview genuinely needs a secret after the deploy has finished, the answer is not a longer lease but a separate workload identity for the environment itself, renewing its own lease. That is more work, and it is the honest design: a credential that must survive for days belongs to something that exists for days, not to a job that finished in ninety seconds.

Auditing what actually happened

The reason to prefer this arrangement over stored secrets is not only that the credentials are short-lived — it is that every issuance is a logged event with an identity attached. Vault’s audit log records which role was assumed, which claims justified it, which paths were read and when. That turns “did anything read the payment key last Tuesday” from an unanswerable question into a query.

Getting that benefit requires the audit device to be enabled and its output retained somewhere queryable, which is a step frequently skipped during setup because nothing fails without it. It is worth doing at the same time as the roles, while the context is fresh: retrofitting audit retention after an incident means reconstructing a period for which no records exist.

A final detail worth checking: the audit log records the claims presented, which is what lets you distinguish a legitimate deploy from an assumption you did not expect. If the trust policy accepts a broad subject pattern, the log will faithfully record that a matching identity assumed the role and tell you very little about which one. Narrow claims make narrow, useful audit records.

Finally, verify the negative case as part of setup: confirm that the preview role is refused when it requests a production path, and that the refusal is logged. A control nobody has watched fail is a control nobody has actually tested, and this one takes a single command to exercise.

← Back to Secrets Injection for Preview Environments