Secrets Rotation Automation: How to Rotate API Keys and Credentials Safely
Zero-downtime rotation strategies, AWS Secrets Manager auto-rotation Lambda patterns, GitHub Actions secret scanning, and GitGuardian integration for leaked credential response.
Secrets Rotation Automation: How to Rotate API Keys and Credentials Safely
Leaked credentials are the leading initial access vector in cloud breaches. GitHub's own research found that millions of secrets are exposed in public repositories every year, and many organizations have no automated process for detecting or rotating them. Manual rotation is error-prone, infrequent, and often skipped entirely because of the operational burden. Automating secrets rotation removes that friction and turns credential hygiene from a quarterly audit item into a continuous background process.
Why Rotation Matters
A secret that never changes has unlimited exposure time. If it is leaked — through a misconfigured log, a former employee, a compromised third-party service, or a git history mistake — the attacker has indefinite access until someone manually discovers and rotates the credential. Rotation bounds the attacker's window to the rotation interval.
For regulated environments, rotation requirements are explicit: PCI-DSS requires periodic rotation of service account passwords and API keys, and HIPAA technical safeguards require regular review of access credentials.
Zero-Downtime Rotation: The Two-Version Pattern
The most common mistake in rotation is rotating a secret before all consumers have switched to the new value. This causes downtime. The safe pattern is:
- Create the new credential alongside the old one.
- Deploy application configuration to use the new credential.
- Verify the new credential works in production.
- Revoke the old credential.
This requires that the secret provider supports two simultaneous active credentials. Most do:
- Database passwords: Create a second DB user, grant identical permissions, rotate all connection strings to the new user, then drop the old user.
- API keys with multiple versions: AWS IAM allows two active access keys per user; rotate the inactive one.
- Stripe API keys: Stripe shows the key once; store in Secrets Manager and create a new one before revoking.
- OAuth client secrets: Most IdPs allow two active client secrets during a transition window.
AWS Secrets Manager Auto-Rotation
AWS Secrets Manager has native rotation support via Lambda functions. For RDS databases, AWS provides managed rotation Lambdas. For custom credentials, you write a Lambda implementing a four-step interface.
RDS PostgreSQL auto-rotation (managed)
aws secretsmanager rotate-secret \
--secret-id prod/myapp/db-password \
--rotation-rules AutomaticallyAfterDays=30 \
--rotate-immediately
AWS will handle the rotation schedule, invoke the built-in RDS rotation Lambda, and update the secret value atomically. Applications using the Secrets Manager SDK fetch the current value on each connection and automatically get the new password after rotation.
Custom rotation Lambda
For non-RDS services, implement a rotation Lambda:
// lambda/rotate-api-key/index.ts
import {
SecretsManagerClient,
GetSecretValueCommand,
PutSecretValueCommand,
DescribeSecretCommand,
UpdateSecretVersionStageCommand,
} from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({});
interface RotationEvent {
SecretId: string;
ClientRequestToken: string;
Step: 'createSecret' | 'setSecret' | 'testSecret' | 'finishSecret';
}
export async function handler(event: RotationEvent): Promise<void> {
const { SecretId, ClientRequestToken, Step } = event;
switch (Step) {
case 'createSecret':
// Generate a new API key and store it as AWSPENDING
const newApiKey = await generateNewApiKeyFromProvider();
await client.send(new PutSecretValueCommand({
SecretId,
ClientRequestToken,
SecretString: JSON.stringify({ apiKey: newApiKey }),
VersionStages: ['AWSPENDING'],
}));
break;
case 'setSecret':
// Activate the AWSPENDING key in the external service
const pending = await getSecretVersion(SecretId, 'AWSPENDING');
await activateKeyInExternalService(pending.apiKey);
break;
case 'testSecret':
// Verify the new key works
const testPending = await getSecretVersion(SecretId, 'AWSPENDING');
const ok = await testApiKey(testPending.apiKey);
if (!ok) throw new Error('New API key test failed');
break;
case 'finishSecret':
// Promote AWSPENDING to AWSCURRENT and demote old AWSCURRENT to AWSPREVIOUS
const desc = await client.send(new DescribeSecretCommand({ SecretId }));
const currentVersion = Object.entries(desc.VersionIdsToStages ?? {})
.find(([, stages]) => stages.includes('AWSCURRENT'))?.[0];
await client.send(new UpdateSecretVersionStageCommand({
SecretId,
VersionStage: 'AWSCURRENT',
MoveToVersionId: ClientRequestToken,
RemoveFromVersionId: currentVersion,
}));
// Revoke old key from the external service after a safe delay
await revokeOldKeyFromExternalService(currentVersion);
break;
}
}
Wire this to a Secrets Manager rotation schedule:
aws secretsmanager rotate-secret \
--secret-id prod/myapp/third-party-api-key \
--rotation-lambda-arn arn:aws:lambda:us-east-1:123456789:function:rotate-api-key \
--rotation-rules AutomaticallyAfterDays=90
Fetching secrets in your application
Never hard-code secrets or read them only at startup. Fetch from Secrets Manager with caching:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({});
interface CachedSecret {
value: string;
fetchedAt: number;
}
const cache = new Map<string, CachedSecret>();
const TTL_MS = 5 * 60 * 1000; // 5 minutes
async function getSecret(secretId: string): Promise<string> {
const cached = cache.get(secretId);
if (cached && Date.now() - cached.fetchedAt < TTL_MS) {
return cached.value;
}
const response = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
const value = response.SecretString ?? '';
cache.set(secretId, { value, fetchedAt: Date.now() });
return value;
}
GitHub Actions Secret Scanning
GitHub scans every push to public repositories for known secret patterns (API keys, tokens, connection strings) across 200+ providers. For private repositories, enable GitHub Advanced Security secret scanning.
Setting up secret scanning on GitHub
# Enable via GitHub API (organization level)
gh api -X PATCH /orgs/{org}/secret-scanning \
--field secret_scanning=true \
--field secret_scanning_push_protection=true
Push protection is the key feature: it blocks the push before the secret reaches the remote, rather than alerting after the fact. Enable it for all repositories.
Responding to a secret scanning alert
When GitHub detects a leaked secret, the alert appears under Security > Secret scanning. Your immediate response should be:
- Revoke the secret immediately — do this before investigating how it leaked.
- Audit usage logs — check if the secret was used by anyone other than your application.
- Issue a new credential and deploy it.
- Scrub git history if the secret is in a commit (use
git filter-repo, notgit filter-branch). - Add to
.gitignoreand verify your CI/CD does not log environment variables.
GitGuardian for Continuous Monitoring
GitGuardian provides real-time scanning of commits across your entire git history, CI/CD pipelines, and connected repositories. It detects 350+ secret types including custom internal patterns.
Integration with GitHub Actions
# .github/workflows/secrets-scan.yml
name: Secrets Scan
on: [push, pull_request]
jobs:
gitguardian:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history scan
- name: GitGuardian scan
uses: GitGuardian/ggshield-action@v1
env:
GITHUB_PUSH_BEFORE_SHA: ${{ github.event.before }}
GITHUB_PUSH_BASE_SHA: ${{ github.event.base }}
GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}
Custom secret patterns
For internal credentials with non-standard formats, define custom detectors:
# .gitguardian.yaml
version: 2
extra-headers:
- name: X-Internal-Prefix
regex: 'MYAPP_KEY_[A-Z0-9]{32}'
keywords:
- 'MYAPP_KEY_'
Rotation for Common Secret Types
Database connection strings
# Rotate RDS master password
aws rds modify-db-instance \
--db-instance-identifier prod-db \
--master-user-password "$(openssl rand -base64 32)" \
--apply-immediately
# Update Secrets Manager
aws secretsmanager put-secret-value \
--secret-id prod/myapp/db \
--secret-string '{"password":"<new-password>","username":"myapp"}'
JWT signing secrets
For JWT secrets, generate a new secret and support both for the TTL of existing tokens:
// Support old and new secrets during rotation window
const SIGNING_SECRETS = [
process.env.JWT_SECRET_NEW, // Sign new tokens with this
process.env.JWT_SECRET_OLD, // Also accept tokens signed with this
].filter((s): s is string => Boolean(s));
function verifyToken(token: string): JWTPayload {
for (const secret of SIGNING_SECRETS) {
try {
return jwt.verify(token, secret) as JWTPayload;
} catch {
// Try next secret
}
}
throw new Error('Invalid token');
}
function signToken(payload: JWTPayload): string {
return jwt.sign(payload, SIGNING_SECRETS[0], { expiresIn: '7d' });
}
After all existing tokens have expired (7 days in this case), remove JWT_SECRET_OLD.
Detecting Exposed Secrets Before They Reach Git
Pre-commit hooks with detect-secrets or truffleHog catch secrets before they are committed:
# Install detect-secrets
pip install detect-secrets
detect-secrets scan > .secrets.baseline
# Add to .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
Automating secrets rotation from creation through detection, rotation, and revocation closes the window of exposure to minutes rather than months. The investment in automation pays for itself the first time it catches a leaked credential before an attacker does.