Pipeline Secrets with GitHub Actions and Vault Dynamic Credentials
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:
- GitHub Actions requests an OIDC token from GitHub’s token endpoint
- The workflow sends the token to Vault’s JWT auth endpoint
- Vault validates the token against GitHub’s JWKS endpoint
- Vault maps the token claims (repo, branch, environment) to a Vault role
- Vault issues a short-lived token with specific permissions
- The workflow uses the Vault token to request dynamic credentials
Credential Scoping
| Claim | Example | Use |
|---|---|---|
repository | acme/checkout-service | Restrict to specific repo |
ref | refs/heads/main | Restrict to specific branch |
environment | production | Restrict to specific GitHub environment |
job_workflow_ref | acme/shared-workflows/.github/workflows/deploy.yml | Restrict 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.