Skip to content

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.

6 min read
CI/CD pipeline diagram showing secret injection points with vault integration and environment isolation boundaries

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.

ymlyaml
# ❌ 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
ymlyaml
# ✅ 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 deploy

OIDC: 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.

tstypescript
// 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
ymlyaml
# 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
jsonjson
// 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.

ymlyaml
# ✅ 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
tstypescript
// 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.

ymlyaml
# ✅ 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")
tstypescript
// 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.

tstypescript
// 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.

ymlyaml
# 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
  }
}
tstypescript
// 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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX