Secrets in CI/CD-Pipelines richtig absichern
Schütze Zugangsdaten in CI/CD-Pipelines mit Secret-Managern, Umgebungsisolation, Least Privilege und Audit-Trails — ohne Tempoverlust.

Jede CI/CD-Pipeline braucht Secrets: API-Keys, Datenbank-Zugangsdaten, Signaturzertifikate, Tokens für Cloud-Provider. Wie du mit diesen Secrets umgehst, entscheidet darüber, ob ein kompromittierter Build-Agent nur ein kleines Ärgernis oder ein katastrophaler Sicherheitsvorfall ist. Das gängigste Muster – Secrets als Umgebungsvariablen in der CI-Plattform zu speichern – ist der Ausgangspunkt, nicht das Ziel. Echte Secret-Sicherheit bedeutet, den Blast Radius zu begrenzen, Zugangsdaten automatisch zu rotieren und Audit-Trails zu führen, die genau zeigen, wer wann worauf zugegriffen hat.
Tatsache ist: CI/CD-Pipelines sind hochattraktive Angriffsziele. Sie haben weitreichenden Zugriff auf Produktionssysteme, führen Code aus Pull Requests aus und verfügen oft über schwächere Zugriffskontrollen als die Produktionsinfrastruktur selbst. Ein einziges Secret, das in einem Build-Log durchsickert, kann eine Kettenreaktion auslösen und die gesamte Infrastruktur kompromittieren.
Das Secret-Sprawl-Problem
Secrets sammeln sich in CI/CD-Systemen wie Staub an. Jede neue Integration bringt eine weitere Zugangsdaten-Art mit sich, und nur wenige Teams behalten den Überblick, wo diese Secrets tatsächlich liegen oder wer darauf zugreifen kann.
# ❌ 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: langlebige Zugangsdaten eliminieren
Die größte Verbesserung, die du erreichen kannst, ist die vollständige Abschaffung langlebiger Zugangsdaten. Mit OpenID Connect (OIDC) können sich CI-Runner bei Cloud-Providern mit kurzlebigen Tokens authentifizieren.
// 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"
}
}
}
]
}Muster für die Secret-Injection
Wenn du nicht um Secrets herumkommst – etwa bei API-Keys von Drittanbietern ohne OIDC-Unterstützung –, injiziere sie mit minimalem Geltungsbereich und minimaler Lebensdauer.
# ✅ 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');
}
}Das Durchsickern von Secrets in Logs verhindern
Build-Logs sind der häufigste Weg, über den Secrets durchsickern. Ein einziges echo $SECRET oder ein zu geschwätziger Dependency-Installer genügt, um Zugangsdaten in Logs zu schreiben, die monatelang erhalten bleiben.
# ✅ 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 und Lifecycle-Management
Secrets, die nie rotiert werden, sind tickende Zeitbomben. Automatisierte Rotation sorgt dafür, dass kompromittierte Zugangsdaten nur für ein begrenztes Zeitfenster nutzbar sind.
// 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`
);
}
}Zugriffe auf Secrets auditieren
Du musst wissen, wer wann auf welche Secrets zugegriffen hat – sowohl für Sicherheitsuntersuchungen als auch für die 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,
}));
}Die wichtigsten Erkenntnisse
Eliminiere langlebige Zugangsdaten, wo immer möglich, durch OIDC-Föderation: CI-Plattformen wie GitHub Actions können sich direkt bei AWS, GCP und Azure mit kurzlebigen Tokens authentifizieren, die auf bestimmte Repositories und Branches beschränkt sind – damit fällt die gefährlichste Klasse von CI/CD-Secrets komplett weg. Beschränke den Geltungsbereich von Secrets auf ein Minimum: Jeder Pipeline-Job sollte nur auf die Secrets zugreifen, die er wirklich braucht, Secrets sollten auf Step-Ebene statt auf Workflow-Ebene injiziert werden, und dynamische Zugangsdaten aus einem Vault mit einstündiger TTL sind erheblich sicherer als dauerhafte API-Keys in der CI-Konfiguration. Build-Logs sind der wichtigste Weg, über den Secrets durchsickern: Maskiere zur Laufzeit abgerufene Secrets mit der Masking-API deiner CI-Plattform, deaktiviere das Echo von Befehlen in Shell-Skripten und logge niemals Connection-Strings oder API-Antworten, die Zugangsdaten enthalten könnten. Automatisiere die Rotation mit einem Deploy-Verify-Promote-Muster, damit selbst bei einem kompromittierten Secret das Zeitfenster der Gefährdung durch dein Rotationsintervall begrenzt wird – und nicht dadurch, wann jemand zufällig daran denkt, das Passwort zu ändern.


