TL;DR Quick Fix
Stop storing long-lived secrets in CI environment variables. Use OIDC federation for keyless authentication:
# GitHub Actions — authenticate to AWS without any stored secrets
jobs:
deploy:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
# No access keys needed — OIDC handles authentication
If you already have leaked secrets, rotate them immediately and run:
# Scan your entire repo history for leaked secrets
gitleaks detect --source . --report-format json --report-path leaks.json
trufflehog git file://. --json > trufflehog-results.json
---
Architecture Overview
---
Why Secrets Leak in CI/CD
Common leak vectors that most teams overlook:
# DANGER: Secret exposed in build logs
echo "Deploying with token: $DEPLOY_TOKEN"
docker build --build-arg API_KEY=$API_KEY .
# DANGER: Secret in environment variable visible in process listing
env | grep -i secret # someone will do this for debugging
# DANGER: Secret committed to .env file
git add .env.production # happens more often than you think
---
OIDC Federation — The Keyless Future
GitHub Actions OIDC with AWS
# .github/workflows/deploy.yml
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
role-session-name: github-actions-${{ github.run_id }}
aws-region: us-east-1
role-duration-seconds: 900 # 15 minutes for short-lived access
- name: Deploy infrastructure
run: |
aws sts get-caller-identity
terraform apply -auto-approve
AWS IAM Role Trust Policy for OIDC
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
}
---
HashiCorp Vault Integration
GitHub Actions with Vault OIDC
- name: Retrieve secrets from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.company.com
method: jwt
role: github-actions-role
jwtGithubAudience: https://vault.company.com
secrets: |
secret/data/production/db DB_PASSWORD | DB_PASSWORD ;
secret/data/production/api API_KEY | API_KEY
Vault Policy Configuration
# vault-policy.hcl — least-privilege access for CI/CD
path "secret/data/production/*" {
capabilities = ["read"]
}
path "aws/creds/deploy-role" {
capabilities = ["read"]
}
path "secret/data/staging/*" {
capabilities = ["deny"]
}
path "database/creds/readonly" {
capabilities = ["read"]
}
Vault Dynamic Secrets for Databases
#!/bin/bash
# get-db-creds.sh — fetch short-lived DB credentials from Vault
set -euo pipefail
VAULT_TOKEN=$(vault write -field=token auth/jwt/login \
role="ci-deploy" \
jwt="$ACTIONS_ID_TOKEN_REQUEST_TOKEN")
export VAULT_TOKEN
DB_CREDS=$(vault read -format=json database/creds/deploy-role)
export DB_USER=$(echo "$DB_CREDS" | jq -r '.data.username')
export DB_PASS=$(echo "$DB_CREDS" | jq -r '.data.password')
export DB_LEASE_ID=$(echo "$DB_CREDS" | jq -r '.lease_id')
echo "::add-mask::$DB_PASS"
./deploy.sh
# Revoke credentials immediately after use
vault lease revoke "$DB_LEASE_ID"
---
AWS Secrets Manager Integration
# GitHub Actions with AWS Secrets Manager
- name: Get secrets from AWS Secrets Manager
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
PROD_DB, arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db-creds
API_KEYS, arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/api-keys
parse-json-secrets: true
# Rotate secrets automatically with Lambda
aws secretsmanager rotate-secret \
--secret-id prod/db-creds \
--rotation-lambda-arn arn:aws:lambda:us-east-1:123456789012:function:rotate-db-creds \
--rotation-rules '{"AutomaticallyAfterDays": 30}'
---
Secret Scanning Pipeline
Pre-commit Hook with gitleaks
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
# .gitleaks.toml — custom rules
[allowlist]
paths = [
'''\.test\.ts$''',
'''fixtures/''',
]
[[rules]]
id = "aws-access-key"
description = "AWS Access Key"
regex = '''AKIA[0-9A-Z]{16}'''
tags = ["aws", "credentials"]
[[rules]]
id = "generic-api-key"
description = "Generic API Key"
regex = '''(?i)(api[_-]?key|apikey)\s[:=]\s['"][a-zA-Z0-9]{20,}['"]'''
tags = ["generic", "api-key"]
CI Pipeline Secret Scanning
# .github/workflows/secret-scan.yml
name: Secret Scanning
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run truffleHog
run: |
docker run --rm -v "$PWD:/repo" \
trufflesecurity/trufflehog:latest \
git file:///repo --only-verified --fail
---
Preventing Secret Exposure in Build Logs
#!/bin/bash
# safe-build.sh — build without leaking secrets
set -euo pipefail
# Use Docker BuildKit secrets mount (never in image layers)
DOCKER_BUILDKIT=1 docker build \
--secret id=npm_token,env=NPM_TOKEN \
--secret id=api_key,env=API_KEY \
-t myapp:latest .
# GitHub Actions — mask secrets in logs
- name: Safe deployment
run: |
echo "::add-mask::${{ steps.vault.outputs.DB_PASSWORD }}"
echo "::add-mask::${{ steps.vault.outputs.API_KEY }}"
./deploy.sh
env:
DB_PASSWORD: ${{ steps.vault.outputs.DB_PASSWORD }}
---
FAQ
What if I have already committed a secret to git history?
Rotate the secret immediately — that is the priority. Then use git filter-repo or BFG Repo Cleaner to remove it from history. But assume the secret is compromised; removing it from git history does not revoke access if someone already cloned the repo.
Is OIDC supported by all CI/CD platforms?
Most major platforms now support OIDC: GitHub Actions, GitLab CI, CircleCI, and Buildkite all have native OIDC token issuance. Jenkins requires the OIDC plugin. For platforms without OIDC, use Vault AppRole auth method with wrapped tokens as the next best option.
How do I handle secrets for local development?
Use a .env.local file that is gitignored, backed by a secrets manager. Tools like direnv with Vault integration or aws-vault for AWS credentials work well. Never share secrets via Slack or email — use a secrets manager with audit logging.
What is the difference between OIDC and service account keys?
Service account keys are long-lived static credentials that can be stolen and used from anywhere. OIDC tokens are short-lived (minutes), bound to a specific workflow run, and cannot be reused. If an OIDC token leaks in logs, it expires before anyone can exploit it.
How do I audit who accessed which secrets?
Vault provides a full audit log of every secret read. AWS CloudTrail logs AssumeRole calls with the session name (which should include the CI run ID). Set up alerts for unusual access patterns — secrets accessed outside business hours or from unexpected IP ranges.
---