Skip to main content
ship it and sleep

Pipeline Secrets with GitHub Actions and Vault Dynamic Credentials

4 min read Chapter 29 of 66

Pipeline Secrets with GitHub Actions and Vault Dynamic Credentials

The Failure

The team stored a long-lived database password in GitHub Actions secrets. The password was shared across all workflows: build, test, deploy, and migration jobs. When the password was rotated, every workflow that used it broke simultaneously. The rotation required updating the secret in GitHub, then re-running 12 failed workflows across 5 repositories.

Vault dynamic credentials eliminate this problem. Each CI run gets a unique, short-lived credential that expires after the job completes. No rotation needed. No shared credentials. If a credential is leaked, it is already expired.

The Mechanism

Vault JWT Auth for GitHub Actions

GitHub Actions can generate OIDC tokens that prove the identity of the workflow run. Vault trusts these tokens via the JWT auth method:

  1. GitHub Actions requests an OIDC token from GitHub’s token endpoint
  2. The workflow sends the token to Vault’s JWT auth endpoint
  3. Vault validates the token against GitHub’s JWKS endpoint
  4. Vault maps the token claims (repo, branch, environment) to a Vault role
  5. Vault issues a short-lived token with specific permissions
  6. The workflow uses the Vault token to request dynamic credentials

Credential Scoping

ClaimExampleUse
repositoryacme/checkout-serviceRestrict to specific repo
refrefs/heads/mainRestrict to specific branch
environmentproductionRestrict to specific GitHub environment
job_workflow_refacme/shared-workflows/.github/workflows/deploy.ymlRestrict to specific reusable workflow

The Implementation

Vault Configuration

# Enable JWT auth method
vault auth enable jwt

# Configure GitHub as the JWT issuer
vault write auth/jwt/config \
  oidc_discovery_url="https://token.actions.githubusercontent.com" \
  bound_issuer="https://token.actions.githubusercontent.com"

# Create a role for checkout-service CI
vault write auth/jwt/role/ci-checkout-service \
  role_type="jwt" \
  bound_audiences="https://github.com/acme" \
  bound_claims_type="glob" \
  bound_claims='{"repository":"acme/checkout-service","ref":"refs/heads/main"}' \
  user_claim="repository" \
  token_ttl="15m" \
  token_max_ttl="30m" \
  policies="ci-checkout-readonly"

# Create a policy with minimal permissions
vault policy write ci-checkout-readonly - <<EOF
# Read-only database credentials for integration tests
path "database/creds/checkout-readonly" {
  capabilities = ["read"]
}

# Read container registry credentials
path "secret/data/ci/registry" {
  capabilities = ["read"]
}
EOF

Dynamic Database Credentials

# Configure Vault database secrets engine
vault secrets enable database

vault write database/config/checkout-db \
  plugin_name=postgresql-database-plugin \
  connection_url="postgresql://{{username}}:{{password}}@db.staging.acme.com:5432/checkout" \
  allowed_roles="checkout-readonly,checkout-readwrite" \
  username="vault-admin" \
  password="$VAULT_DB_ADMIN_PASSWORD"

# Create a role that generates short-lived credentials
vault write database/roles/checkout-readonly \
  db_name=checkout-db \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
  default_ttl="15m" \
  max_ttl="30m"

GitHub Actions Workflow

# HARDENED: Vault dynamic credentials in CI
name: ci
on: [push]

permissions:
  id-token: write # Required for OIDC token
  contents: read

jobs:
  integration-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Get Vault credentials
        id: vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.acme.com
          method: jwt
          role: ci-checkout-service
          jwtGithubAudience: https://github.com/acme
          exportEnv: false
          secrets: |
            database/creds/checkout-readonly username | DB_USER ;
            database/creds/checkout-readonly password | DB_PASS ;
            secret/data/ci/registry token | REGISTRY_TOKEN

      - name: Run integration tests
        env:
          DATABASE_URL: "postgresql://${{ steps.vault.outputs.DB_USER }}:${{ steps.vault.outputs.DB_PASS }}@db.staging.acme.com:5432/checkout"
        run: |
          echo "Using dynamic credential (expires in 15 minutes)"
          npm run test:integration
        # Credential auto-expires after 15 minutes
        # No cleanup needed

OIDC for Cloud Providers (No Vault Required)

# HARDENED: AWS credentials via OIDC — no static keys
permissions:
  id-token: write
  contents: read

steps:
  - name: Configure AWS credentials
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/ci-checkout-deploy
      role-session-name: ci-${{ github.run_id }}
      aws-region: us-east-1
      # No access key ID or secret access key stored anywhere

The Gate

The Vault role restricts credential issuance to specific repositories and branches. A workflow running from a fork or a feature branch cannot obtain production credentials. The bound_claims in the Vault role definition enforce this at the authentication layer.

If the OIDC token claims do not match the Vault role’s bound_claims, Vault rejects the authentication request and the job fails immediately with a clear error.

The Recovery

Vault credential expires during a long-running job: Increase the token_ttl in the Vault role to match the maximum expected job duration plus a buffer. Do not set it longer than necessary.

GitHub OIDC endpoint is unavailable: The workflow cannot authenticate to Vault. The job fails. Do not fall back to static credentials. Wait for GitHub’s OIDC service to recover.

Vault role misconfigured: The workflow gets “permission denied” from Vault. Check the bound_claims against the workflow’s actual token claims. Use vault read auth/jwt/role/ci-checkout-service to inspect the role configuration.