Hardcoded credentials are one of the most persistent and dangerous security vulnerabilities in modern software delivery. Database passwords committed to .env files, AWS access keys stored as long-lived GitHub repository secrets, API tokens embedded in CI configuration, these are not edge cases. They are standard practice at the majority of engineering teams, and they represent a ticking clock.
When a long-lived credential leaks, through a misconfigured repository, a disgruntled employee, or a supply chain compromise, the window of exposure is measured in the lifetime of the credential, often months or years. The blast radius includes every system that credential has access to.
The solution is not better secret hygiene on static credentials. It is eliminating static credentials entirely, replacing them with dynamic, short-lived tokens issued on demand and automatically expired. HashiCorp Vault and GitHub OIDC make this achievable without rewriting your CI/CD pipelines from scratch.
This guide covers how to design and implement a secrets management architecture that removes hardcoded credentials from your CI/CD workflows permanently.
Why Static Secrets Are a Structural Problem
Static credentials, long-lived API keys, database passwords, and cloud access keys, have three fundamental weaknesses that secret rotation and better storage cannot fully address:
Broad access scope. A static AWS access key issued to a CI pipeline typically has fixed permissions. Over time, those permissions accumulate as new capabilities are needed and old ones are never revoked, a textbook case of privilege creep.
Long exposure windows. A leaked static credential remains valid until it is manually rotated. Discovery of a leak is rarely immediate, the average time to detect a credential compromise is measured in days to weeks, during which an attacker has unrestricted access.
No audit trail per workflow run. When ten pipeline runs use the same static credential, your cloud provider's access logs show ten identical requests from the same key, making it impossible to attribute access to a specific pipeline, branch, or commit.
Dynamic secrets solve all three: each pipeline run gets a unique, scoped, time-limited credential, automatically expired when the job completes.
The Architecture: GitHub OIDC + HashiCorp Vault
The zero-static-credentials architecture has two components working in concert:
GitHub OIDC (OpenID Connect): GitHub Actions can generate a signed JWT token, an OIDC token, that cryptographically proves the identity of the workflow that requested it. This token includes claims about the repository, branch, environment, and workflow that generated it. Critically, it requires no pre-shared secret to verify, Vault validates it directly against GitHub's public JWKS endpoint.
HashiCorp Vault: Vault receives the OIDC token from the GitHub Actions runner, validates it, maps it to a role with defined permissions, and issues a short-lived Vault token in return. The pipeline then uses that Vault token to fetch exactly the secrets it needs, no more, no less.
The request flow looks like this:
GitHub Actions Runner
│
│ 1. Request OIDC JWT from GitHub
▼
GitHub OIDC Provider
│
│ 2. Returns signed JWT (claims: repo, branch, workflow, environment)
▼
GitHub Actions Runner
│
│ 3. Exchange JWT for Vault token (POST /auth/jwt/login)
▼
HashiCorp Vault
│
│ 4. Validate JWT against GitHub JWKS, map to role, issue Vault token
▼
GitHub Actions Runner
│
│ 5. Use Vault token to read secrets (GET /secret/data/...)
▼
HashiCorp Vault
│
│ 6. Return scoped, time-limited secret
▼
GitHub Actions Runner
│
│ 7. Use secret in pipeline step (expires when job completes)
▼
Pipeline Step
No hardcoded credential appears anywhere in this flow. The GitHub runner never holds a long-lived secret, only a short-lived Vault token scoped to the secrets needed for that specific job.
Step 1 - Configure Vault JWT Authentication
Enable and configure the JWT authentication backend in Vault to trust GitHub's OIDC provider:
# Enable JWT auth backend
vault auth enable jwt
# Configure GitHub as the trusted OIDC provider
vault write auth/jwt/config \
oidc_discovery_url="https://token.actions.githubusercontent.com" \
bound_issuer="https://token.actions.githubusercontent.com"
Vault will automatically fetch and cache GitHub's public keys from the discovery URL, no manual key management required.
Step 2 - Create Vault Policies
Vault policies define what secrets a role can access. Apply the principle of least privilege, each role gets access only to the secrets its corresponding workflow actually needs:
# vault/policies/orders-service-deploy.hcl
path "secret/data/production/orders-service/*" {
capabilities = ["read"]
}
path "secret/data/production/shared/database" {
capabilities = ["read"]
}
# Explicitly deny access to other services' secrets
path "secret/data/production/payments-service/*" {
capabilities = ["deny"]
}
# vault/policies/terraform-plan.hcl
# Read-only access to infrastructure credentials for planning
path "aws/creds/terraform-plan-role" {
capabilities = ["read"]
}
# Apply policies to Vault
vault policy write orders-service-deploy vault/policies/orders-service-deploy.hcl
vault policy write terraform-plan vault/policies/terraform-plan.hcl
Step 3 - Create Vault Roles Bound to GitHub Claims
Vault roles map GitHub OIDC token claims to Vault policies. This is where you define which GitHub workflows can access which secrets, using the claims in the OIDC token as the binding condition:
# Role for deploying orders-service from the main branch only
vault write auth/jwt/role/orders-service-deploy \
role_type="jwt" \
bound_audiences="https://github.com/your-org" \
bound_claims_type="glob" \
bound_claims='{
"repository": "your-org/orders-service",
"ref": "refs/heads/main",
"environment": "production"
}' \
user_claim="actor" \
policies="orders-service-deploy" \
ttl="15m" # Vault token expires in 15 minutes
# Role for Terraform planning — any branch, read-only AWS credentials
vault write auth/jwt/role/terraform-plan \
role_type="jwt" \
bound_audiences="https://github.com/your-org" \
bound_claims='{
"repository": "your-org/infrastructure",
"workflow": ".github/workflows/terraform-plan.yml"
}' \
user_claim="actor" \
policies="terraform-plan" \
ttl="30m"
The bound_claims binding is the critical security control. A workflow from your-org/payments-service requesting the orders-service-deploy role will be rejected, even if it has a valid GitHub OIDC token, because the repository claim doesn't match.
Step 4 - Configure GitHub Actions Workflows
With Vault configured, update your GitHub Actions workflows to fetch secrets dynamically:
# .github/workflows/deploy-orders-service.yml
name: Deploy Orders Service
on:
push:
branches: [main]
permissions:
id-token: write # required to request GitHub OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # must match Vault role's bound_claims
steps:
- uses: actions/checkout@v4
- name: Authenticate to Vault via GitHub OIDC
uses: hashicorp/vault-action@v3
id: vault
with:
url: ${{ secrets.VAULT_ADDR }}
method: jwt
role: orders-service-deploy
secrets: |
secret/data/production/orders-service/database DB_PASSWORD | DATABASE_PASSWORD ;
secret/data/production/orders-service/database DB_HOST | DATABASE_HOST ;
secret/data/production/shared/stripe API_KEY | STRIPE_API_KEY ;
- name: Run database migrations
run: npm run migration:run
env:
DATABASE_URL: postgresql://${{ env.DATABASE_HOST }}:5432/orders?password=${{ env.DATABASE_PASSWORD }}
- name: Deploy to Kubernetes
run: kubectl apply -f k8s/
env:
STRIPE_API_KEY: ${{ env.STRIPE_API_KEY }}
The hashicorp/vault-action step handles the full OIDC exchange automatically, requesting the GitHub token, authenticating to Vault, and injecting the fetched secrets as environment variables for subsequent steps. The secrets are masked in logs and scoped to the job, they do not persist beyond the workflow run.
Step 5 - Dynamic Database Credentials with Vault
For databases, Vault can go further than storing static passwords, it can generate unique, short-lived database credentials for each pipeline run using the Vault database secrets engine:
# Enable the database secrets engine
vault secrets enable database
# Configure the PostgreSQL connection
vault write database/config/orders-db \
plugin_name=postgresql-database-plugin \
allowed_roles="orders-service-ci" \
connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/orders" \
username="vault-admin" \
password="$DB_ADMIN_PASSWORD"
# Define a role that generates credentials on demand
vault write database/roles/orders-service-ci \
db_name="orders-db" \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl="30m" \
max_ttl="1h"
Now update the Vault policy to allow reading dynamic credentials:
# vault/policies/orders-service-ci.hcl
path "database/creds/orders-service-ci" {
capabilities = ["read"]
}
Each CI pipeline run that requests database credentials receives a unique PostgreSQL role, created just for that run and automatically dropped when the TTL expires. No two pipeline runs share credentials. A leaked credential from a pipeline run is valid for a maximum of 30 minutes and grants access only to the tables that role needs.
Step 6 - Auditing and Monitoring
Every Vault operation generates an audit log entry. Enable the file audit backend and ship logs to your SIEM:
# Enable Vault audit logging
vault audit enable file file_path=/var/log/vault/audit.log
Each entry records the requesting token, the path accessed, the timestamp, and the response status, giving you a complete, tamper-evident trail of every secret access across all pipeline runs.
Alert on these patterns in your log aggregator:
- Any access to
secret/data/production/*outside of expected workflow hours - Failed authentication attempts against Vault JWT roles
- Policy violations (denied path access attempts)
- Unusually high secret read volume from a single token
Step 7 - Migrating from GitHub Repository Secrets
Migrating an existing CI/CD system from static GitHub repository secrets to Vault is a multi-step process. Approach it incrementally:
Phase 1 - Audit existing secrets. Inventory every secret in your GitHub organization's repository and environment settings. Classify each by service, sensitivity, and which workflows consume it.
Phase 2 - Replicate in Vault. Write all existing static secrets to Vault under a structured path hierarchy (secret/data/{environment}/{service}/{name}).
Phase 3 - Migrate non-critical workflows first. Start with staging and development workflows, lower risk, full learning opportunity. Validate that secret fetching works correctly before touching production.
Phase 4 - Migrate production workflows. Roll out Vault integration to production workflows one service at a time, with a rollback plan for each.
Phase 5 - Retire static secrets. Once all workflows are migrated and validated, delete the corresponding GitHub repository secrets. Validate that no workflow fails. Revoke access for any remaining long-lived credentials that were part of the old system.
Common Pitfalls to Avoid
Overly permissive Vault policies. Granting secret/data/production/* with ["read"] to a deployment role means that role can read every production secret, not just the ones it needs. Always scope policies to specific paths.
Skipping the environment claim binding. Without binding roles to a specific GitHub environment, any workflow in your repository could request production secrets. Always require the environment claim for production roles and protect GitHub environments with required reviewers.
Not setting TTLs. A Vault token without a TTL is effectively a long-lived credential, defeating the purpose of the architecture. Always set ttl and max_ttl on roles.
Logging secret values. The vault-action step masks secrets in GitHub Actions logs, but custom scripts that echo environment variables will expose them. Audit every workflow step that uses fetched secrets.
Conclusion
Hardcoded credentials are a solved problem, not by better storage of static secrets, but by eliminating the need for static secrets entirely. The combination of GitHub OIDC for cryptographic workflow identity and HashiCorp Vault for dynamic, scoped, time-limited secret issuance removes the structural weaknesses of static credentials at their root.
Every CI/CD pipeline that runs with this architecture gets credentials that are unique to that run, scoped to exactly what it needs, and automatically expired when the job completes. A leaked credential compromises a single pipeline run, not your entire production environment.
The migration requires upfront investment. But so does recovering from a credential compromise. The difference is that the migration is planned, controlled, and bounded, and the recovery rarely is.
Running workloads on AWS or GCP instead of relying on Vault for cloud credentials? AWS IAM Roles for GitHub Actions and GCP Workload Identity Federation follow the same OIDC pattern, no Vault required for cloud-native secrets.
Top comments (0)