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. 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.

← Back to Secrets Injection for Preview Environments