Skip to main content
Security·6 min read

Handling Secrets Safely in CI/CD Without Hardcoding or Leaks

Learn how to eliminate hardcoded secrets from CI/CD pipelines using OIDC federation, HashiCorp Vault, short-lived tokens, and automated secret scanning with gitleaks and truffleHog.

DT

DevOps Engineer & Technical Writer

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

GitHub Actions Runner

OIDC Token Issued

OIDC Identity Provider

JWT Verification

HashiCorp Vault

Short-lived Credentials

AWS STS

AssumeRoleWithWebIdentity

Pre-commit Scanning

gitleaks / truffleHog

Git Repository

No secrets in code

---

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.

---