Securing Secrets in CI/CD Pipelines
Protect credentials in CI/CD pipelines with secret managers, environment isolation, least-privilege access and audit trails — without slowing deploys.

Every CI/CD pipeline needs secrets—API keys, database credentials, signing certificates, cloud provider tokens. How you handle those secrets determines whether a compromised build agent means a minor inconvenience or a catastrophic breach. The most common pattern—storing secrets as environment variables in the CI platform—is the baseline, not the goal. Real secret security means limiting blast radius, rotating credentials automatically, and maintaining audit trails that show exactly who accessed what and when.
The reality is that CI/CD pipelines are high-value targets. They have broad access to production systems, run code from pull requests, and often have weaker access controls than production infrastructure. A single leaked secret from a build log can cascade into a full infrastructure compromise.
The Secret Sprawl Problem
Secrets accumulate across CI/CD systems like dust. Every integration adds another credential, and few teams track where secrets actually live or who can access them.
# ❌ The "works but terrifying" approach
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
# 15 secrets scattered across GitHub settings
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
REDIS_URL: ${{ secrets.REDIS_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
# Nobody remembers who added these or when they were last rotated
steps:
- run: npm run deploy
# All 15 secrets available to every step
# Any compromised dependency can read them all# ✅ Scoped secrets with minimal exposure
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
# No secrets needed for testing
steps:
- uses: actions/checkout@v4
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
permissions:
id-token: write # For OIDC — no stored credentials
contents: read
steps:
- uses: actions/checkout@v4
# Authenticate via OIDC — no long-lived secrets
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/deploy-role
aws-region: us-east-1
# Fetch secrets from vault, scoped to this deployment
- name: Fetch deployment secrets
id: secrets
run: |
# Secrets fetched at runtime, never stored in CI config
vault kv get -format=json secret/prod/app | \
jq -r '.data.data | to_entries[] | "\(.key)=\(.value)"' >> "$GITHUB_ENV"
- run: npm run deployOIDC: Eliminating Long-Lived Credentials
The biggest improvement you can make is eliminating long-lived credentials entirely. OpenID Connect (OIDC) lets CI runners authenticate with cloud providers using short-lived tokens.
// How OIDC works in CI/CD:
// 1. CI runner requests a JWT from the CI platform
// 2. Cloud provider validates the JWT against the CI platform's OIDC endpoint
// 3. Cloud provider issues short-lived credentials
// 4. Credentials expire after the job finishes
// ❌ Long-lived access key stored in CI secrets
// - Valid until manually rotated (often never)
// - If leaked, attacker has indefinite access
// - No way to scope to specific workflows
// ✅ OIDC: no secrets to leak
// - Token valid for ~1 hour
// - Scoped to specific repo/branch/workflow
// - Cloud provider validates the source# GitHub Actions OIDC with AWS
jobs:
deploy:
permissions:
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy
aws-region: us-east-1
# No access key or secret — OIDC handles it// AWS IAM trust policy: only trust specific repo and branch
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789: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:myorg/myrepo:ref:refs/heads/main"
}
}
}
]
}Secret Injection Patterns
When you must use secrets (third-party API keys that don't support OIDC), inject them with minimal scope and duration.
# ✅ Fetch from vault at runtime with short TTL
- name: Get database credentials
id: db-creds
run: |
# Dynamic credentials: vault creates a temporary database user
# that expires after 1 hour
CREDS=$(vault read -format=json database/creds/deploy-role)
echo "DB_USER=$(echo $CREDS | jq -r '.data.username')" >> "$GITHUB_OUTPUT"
echo "DB_PASS=$(echo $CREDS | jq -r '.data.password')" >> "$GITHUB_OUTPUT"
- name: Run migrations
env:
DB_USER: ${{ steps.db-creds.outputs.DB_USER }}
DB_PASS: ${{ steps.db-creds.outputs.DB_PASS }}
run: npm run migrate
# After this step, credentials are no longer in environment// Application-side: never log secrets, even accidentally
// ❌ Dangerous: secrets can appear in error messages
async function connectDatabase(url: string) {
try {
await pool.connect(url);
} catch (error) {
// This logs the full connection string including password
console.error('Failed to connect:', error.message);
throw error;
}
}
// ✅ Safe: redact sensitive portions
async function connectDatabase(url: string) {
try {
await pool.connect(url);
} catch (error) {
const safeUrl = url.replace(
/\/\/([^:]+):([^@]+)@/,
'//$1:***@'
);
console.error(`Failed to connect to ${safeUrl}`);
throw new Error('Database connection failed');
}
}Preventing Secret Leakage in Logs
Build logs are the most common vector for secret leakage. A single echo $SECRET or a verbose dependency installer can dump credentials into logs that persist for months.
# ✅ GitHub Actions: secrets are automatically masked in logs
# But only secrets stored in GitHub — not secrets fetched at runtime
- name: Mask runtime secrets
run: |
API_KEY=$(vault read -field=key secret/api)
# Register with the runner's masking system
echo "::add-mask::$API_KEY"
echo "API_KEY=$API_KEY" >> "$GITHUB_ENV"
# ✅ Prevent accidental logging in scripts
- name: Deploy with secret protection
run: |
set +x # Disable command echoing
# Use process substitution to avoid secrets in /proc
deploy-tool --config <(echo "$DEPLOY_CONFIG")// Build-time secret protection
class SecretGuard {
private secrets: Set<string>;
constructor(secretValues: string[]) {
this.secrets = new Set(secretValues);
}
// Scan output before it reaches logs
sanitize(output: string): string {
let sanitized = output;
for (const secret of this.secrets) {
if (secret.length < 4) continue; // Don't mask tiny strings
sanitized = sanitized.replaceAll(secret, '***REDACTED***');
}
return sanitized;
}
// Wrap console to prevent accidental logging
wrapConsole(): void {
const original = console.log;
console.log = (...args: unknown[]) => {
const sanitized = args.map((arg) =>
typeof arg === 'string' ? this.sanitize(arg) : arg
);
original.apply(console, sanitized);
};
}
}Rotation and Lifecycle Management
Secrets that never rotate are ticking time bombs. Automated rotation ensures compromised credentials have a limited window of usefulness.
// Automated secret rotation workflow
interface SecretRotationConfig {
secretPath: string;
rotationDays: number;
generator: () => Promise<string>;
deployer: (newSecret: string) => Promise<void>;
verifier: () => Promise<boolean>;
}
async function rotateSecret(config: SecretRotationConfig): Promise<void> {
const newSecret = await config.generator();
// Store new version (old version still active)
await vault.write(`${config.secretPath}/pending`, {
value: newSecret,
created: new Date().toISOString(),
});
// Deploy new secret to consuming services
await config.deployer(newSecret);
// Verify services work with new secret
const healthy = await config.verifier();
if (healthy) {
// Promote new secret, archive old one
await vault.write(config.secretPath, { value: newSecret });
await vault.delete(`${config.secretPath}/pending`);
console.log(`Rotated ${config.secretPath} successfully`);
} else {
// Rollback: remove pending secret
await vault.delete(`${config.secretPath}/pending`);
throw new Error(
`Rotation failed for ${config.secretPath} — rolled back`
);
}
}Auditing Secret Access
You need to know who accessed which secrets and when—both for security investigations and compliance.
# Vault audit log configuration
storage "raft" {
path = "/vault/data"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/vault/certs/cert.pem"
tls_key_file = "/vault/certs/key.pem"
}
# Enable audit logging — every secret access is recorded
audit {
type = "file"
path = "/vault/logs/audit.log"
options = {
# HMAC secret values so they don't appear in audit logs
hmac_accessor = true
log_raw = false
}
}// Query audit logs for security review
interface AuditQuery {
secretPath?: string;
actor?: string;
operation?: 'read' | 'write' | 'delete';
startTime: Date;
endTime: Date;
}
async function querySecretAccess(query: AuditQuery) {
// "Who accessed production database credentials this week?"
// "Were any secrets accessed outside business hours?"
// "Which CI jobs read the Stripe API key?"
const logs = await vault.auditLog.query({
path: query.secretPath,
auth_entity: query.actor,
operation: query.operation,
start_time: query.startTime.toISOString(),
end_time: query.endTime.toISOString(),
});
return logs.map((entry) => ({
timestamp: entry.time,
actor: entry.auth.display_name,
operation: entry.request.operation,
path: entry.request.path,
source_ip: entry.request.remote_address,
}));
}Key Takeaways
Eliminate long-lived credentials using OIDC federation whenever possible—CI platforms like GitHub Actions can authenticate directly with AWS, GCP, and Azure using short-lived tokens scoped to specific repositories and branches, removing the most dangerous class of CI/CD secrets entirely. Scope secrets to the minimum: each pipeline job should only access the secrets it needs, secrets should be injected at the step level rather than the workflow level, and dynamic credentials from a vault with one-hour TTLs are vastly safer than permanent API keys stored in CI settings. Build logs are the primary secret leakage vector—mask runtime-fetched secrets using your CI platform's masking API, disable command echoing in shell scripts, and never log connection strings or API responses that might contain credentials. Automate rotation with a deploy-verify-promote pattern so that even if a secret is compromised, the window of exposure is bounded by your rotation interval rather than by whenever someone remembers to change the password.


