The Rotation Problem
Everyone agrees you should rotate API keys regularly. Nobody actually does it. Here's why:
- Downtime fear. If you revoke the old key before the new one is deployed, your service breaks. If you deploy the new key before revoking the old one, you have two active keys — and the old one might be leaked.
- No versioning. When you rotate a key in a secrets manager, you overwrite the old value. There's no concept of "v1 is being retired, v2 is active." If something goes wrong, you can't roll back.
- No audit trail. When did the key rotate? Who triggered it? What was the previous value's hash? Most secrets managers don't track this — and auditors notice.
- Manual process. Generate a new key in the provider console, copy it to your CI secrets, update the service, revoke the old key. Five manual steps, zero automation, and someone always forgets step 4.
APIAuth solves this with versioned rotation: each key has a version counter, the previous value is hashed out (not deleted), and the full rotation history is recorded in the encrypted keystore.
How Versioned Rotation Works
When you rotate a key in APIAuth, three things happen atomically:
A new key value is generated. The new value uses the same cryptographically secure generation as apiauth generate — 32 bytes of randomness, base64url-encoded, with your chosen prefix.
The version counter increments. The key entry goes from version: 1 to version: 2. The previous hash is stored in previous_hash. The rotation timestamp is recorded in rotated_at.
The old plaintext is destroyed. You see the new key value once — at rotation time. After that, only the hash is stored. This means the old key can be verified (you can check if a value matches the previous hash) but never recovered.
# Rotate a key
apiauth rotate abc123def456
# Output:
# ✓ Rotated abc123def456 (v2)
# New key: ak_QmZ4cF9nY3RrN2p1VHV4eQ
# Previous value has been hashed out. Save the new value.
Key insight: The version counter lets your service accept both v1 and v2 during the transition window. Your application checks the key against the current hash or the previous hash. Once v2 is deployed everywhere, v1's hash is no longer checked. Zero downtime.
The Zero-Downtime Rotation Pattern
Here's the complete workflow for rotating an API key with zero service interruption:
Step 1: Rotate the Key
# Rotate and capture the new value
NEW_KEY=$(apiauth rotate abc123def456 --expiry-days 90 2>&1 | grep "New key:" | awk '{print $3}')
echo "New key value: $NEW_KEY"
echo "Key is now at version 2"
Step 2: Deploy the New Key Alongside the Old One
Add the new key to your service's configuration without removing the old one. Your service should accept keys that match either the current hash or the previous hash:
# In your application's key verification logic:
# Accept both current and previous key hashes during rotation window
def verify_key(provided_key):
key_hash = sha256(provided_key).hexdigest()
stored = keystore.get(key_id)
# Current key (v2) — always valid
if key_hash == stored["key_hash"]:
return True
# Previous key (v1) — valid during rotation window
if key_hash == stored.get("previous_hash"):
log.warning(f"Deprecated key version used: {key_id} v{stored['version']-1}")
return True
return False
Using APIAuth's built-in verify: apiauth verify ak_QmZ4cF9n... checks against the current hash. To support the transition window, use apiauth show <key-id> to retrieve the previous_hash field and compare in your application code.
Step 3: Validate the New Key Is Working
# Verify the new key is accepted by your service
curl -H "Authorization: Bearer $NEW_KEY" https://api.example.com/health
# If the response is 200, the new key is deployed and active
Step 4: Remove the Old Key from Rotation
Once the new key is confirmed working, update your service to only accept the current version:
# Remove the old key from your service's accepted keys
# This is a deploy — not a key revocation
# Option A: Deploy a config change that removes the previous_hash check
# Option B: Wait for the old key to expire naturally
# Revoke the old key only if it was compromised:
apiauth revoke abc123def456 # This revokes the ENTIRE key, including the new version
# ⚠ Do NOT revoke during rotation — only revoke if the key is compromised
Revoke vs. Rotate: apiauth revoke marks the entire key entry as revoked — both v1 and v2. It's for compromised keys, not for completing a rotation. Rotation is complete when your service only accepts the current version hash.
Automated Rotation in GitHub Actions
Here's a complete CI/CD workflow that rotates a key, updates the GitHub secret, and verifies the new key — all automatically on a schedule.
name: Rotate API Key
on:
schedule:
# Run every 90 days at 09:00 UTC
- cron: '0 9 1 */3 *'
workflow_dispatch: # Allow manual trigger
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install APIAuth
run: pip install apiauth
- name: Rotate the key
id: rotate
env:
APIAUTH_MASTER_KEY: ${{ secrets.APIAUTH_MASTER_KEY }}
run: |
# Rotate the key and capture the new value
OUTPUT=$(apiauth rotate ${{ secrets.API_KEY_ID }} --expiry-days 90 2>&1)
echo "$OUTPUT"
# Extract the new key value
NEW_KEY=$(echo "$OUTPUT" | grep "New key:" | awk '{print $3}')
echo "new_key=$NEW_KEY" >> "$GITHUB_OUTPUT"
# Verify rotation happened
VERSION=$(echo "$OUTPUT" | grep -oP 'v\K[0-9]+')
echo "Key rotated to version $VERSION"
- name: Update GitHub secret
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
run: |
# Update the repository secret with the new key value
gh secret set API_KEY_VALUE \
--body "${{ steps.rotate.outputs.new_key }}" \
--repo ${{ github.repository }}
- name: Verify new key works
run: |
# Test the new key against your service
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${{ steps.rotate.outputs.new_key }}" \
https://api.example.com/health)
if [ "$HTTP_STATUS" != "200" ]; then
echo "ERROR: New key returned HTTP $HTTP_STATUS"
exit 1
fi
echo "✓ New key validated (HTTP $HTTP_STATUS)"
- name: Run audit
env:
APIAUTH_MASTER_KEY: ${{ secrets.APIAUTH_MASTER_KEY }}
run: |
apiauth audit
- name: Notify on success
if: success()
run: |
echo "✓ Key rotation completed successfully"
- name: Alert on failure
if: failure()
run: |
echo "✗ Key rotation failed — investigate immediately"
# Add Slack/Discord/PagerDuty notification here
Why this is safe: The rotation is atomic — the new key is generated and stored before the old hash is retired. If any step fails, the old key remains valid. The apiauth verify command still accepts the previous hash during the transition window, so your service doesn't break mid-deploy.
Export Formats for Different CI Platforms
APIAuth exports active (non-revoked, non-expired) keys in four formats, each designed for a specific CI/CD integration:
Shell Environment Variables
apiauth export --format env
# Output:
export APIGATEWAY_ID="abc123def456"
export APIGATEWAY_SERVICE="api-gateway"
export APIGATEWAY_CREATED="2025-08-01T12:00:00Z"
export APIGATEWAY_EXPIRES="2025-11-01T12:00:00Z"
Dotenv Format
apiauth export --format dotenv
# Output (no "export" prefix — ready for .env files):
APIGATEWAY_ID="abc123def456"
APIGATEWAY_SERVICE="api-gateway"
APIGATEWAY_CREATED="2025-08-01T12:00:00Z"
JSON
apiauth export --format json
# Output: JSON array of active key metadata
[
{
"id": "abc123def456",
"type": "api_key",
"name": "API Gateway Key",
"service": "api-gateway",
"version": 3,
"created_at": "2025-08-01T12:00:00Z",
"rotated_at": "2025-11-01T09:00:00Z",
"expires_at": "2026-02-01T12:00:00Z"
}
]
GitHub Actions
apiauth export --format github-actions
# Output:
echo "APIGATEWAY_ID=abc123def456" >> $GITHUB_ENV
echo "APIGATEWAY_SERVICE=api-gateway" >> $GITHUB_ENV
echo "APIGATEWAY_CREATED=2025-08-01T12:00:00Z" >> $GITHUB_ENV
echo "APIGATEWAY_EXPIRES=2025-11-01T12:00:00Z" >> $GITHUB_ENV
# Or add to .github/workflows/*.yml env: block:
env:
APIGATEWAY_ID: "abc123def456"
APIGATEWAY_SERVICE: "api-gateway"
Security note: apiauth export exports key metadata (IDs, services, expiry dates) — not the plaintext key values. The plaintext is shown only at creation and rotation time. Export is safe to log, commit, or include in CI output without leaking secrets.
Audit Trail: What Your Auditor Wants to See
Run apiauth audit to see the health status of every key in your keystore:
apiauth audit
# Output:
# ✓ All 5 keys are healthy
# Or if there are issues:
# ✗ 1 EXPIRED key(s):
# - xyz789 (api-gateway) — expired 2025-10-15
#
# ⚠ 2 EXPIRING key(s) (within 7 days):
# - abc123 (payment-service) — expires 2025-11-03
# - def456 (auth-service) — expires 2025-11-05
#
# ✗ 1 REVOKED key(s):
# - ghi789 (legacy-api) — revoked 2025-09-01
For a detailed audit report per key:
apiauth show abc123def456
# Output includes:
# - Current version number
# - Rotation timestamp (rotated_at)
# - Previous hash (anonymized)
# - Creation date
# - Expiry date
# - Revocation status
The version, rotated_at, and previous_hash fields together form a complete rotation history. When your auditor asks "prove this key was rotated on schedule," you show them the rotated_at timestamp and the incremented version number.
Scheduled Audit in CI/CD
Don't wait for a breach to discover expired keys. Run apiauth audit on a schedule and fail the pipeline if any keys are expired:
name: Key Audit
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 09:00 UTC
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Install APIAuth
run: pip install apiauth
- name: Run audit
env:
APIAUTH_MASTER_KEY: ${{ secrets.APIAUTH_MASTER_KEY }}
run: |
# Run audit and capture output
apiauth audit > audit-report.txt 2>&1
cat audit-report.txt
# Fail if any keys are expired
if grep -q "EXPIRED" audit-report.txt; then
echo "::error::Expired keys found — rotate immediately"
exit 1
fi
# Warn if keys are expiring soon
if grep -q "EXPIRING" audit-report.txt; then
echo "::warning::Keys expiring within 7 days — schedule rotation"
fi
- name: Upload audit report
if: always()
uses: actions/upload-artifact@v4
with:
name: key-audit-report
path: audit-report.txt
Keystore Security Model
APIAuth stores keys in an encrypted local keystore at ~/.apiauth/:
- Encryption: AES-256-GCM. The master key is stored separately from the encrypted data.
- Plaintext exposure: Key values are shown only at
generateandrotatetime. They are never stored in plaintext. - Hash storage: SHA-256 hashes are stored for verification. The
previous_hashfield retains the v1 hash after rotation — allowing dual-key verification without storing the old plaintext. - Prefix storage: Only the first 20 characters of the key are stored (for identification in
listoutput). This is safe to display in logs.
When to Rotate vs. When to Revoke
| Scenario | Command | Effect |
|---|---|---|
| Scheduled rotation (every 90 days) | apiauth rotate <id> |
New key generated, version increments, old value hashed out |
| Key approaching expiry | apiauth rotate <id> --expiry-days 90 |
Same as above + resets expiry clock |
| Key committed to Git | apiauth revoke <id> |
Key marked revoked — cannot be verified. Generate a new key separately. |
| Key shared with unauthorized person | apiauth revoke <id> |
Same — immediate invalidation. No transition window. |
| Service decommissioned | apiauth revoke <id> |
Clean revocation. No replacement needed. |
Critical distinction: rotate keeps the key entry alive with a new value. revoke kills it. If you revoke when you meant to rotate, you'll need to generate an entirely new key and update every service that references the old key ID. There is no "un-revoke."
Install and Start Rotating
# pip
pip install apiauth
# Homebrew (macOS / Linux)
brew tap Coding-Dev-Tools/tap
brew install apiauth
# Scoop (Windows)
scoop bucket add Coding-Dev-Tools https://github.com/Coding-Dev-Tools/scoop-bucket
scoop install apiauth
# Generate your first key
apiauth generate api-key --name "Production API" --service "api-gateway" --expiry-days 90
# Rotate it when the time comes
apiauth rotate <key-id> --expiry-days 90
Star APIAuth on GitHub
What's Next
APIAuth is one of 11 CLI tools in the DevForge suite. If you're managing API credentials, also check out:
- Envault — diff, sync, and rotate environment variables across dev/staging/prod
- APIGhost — turn any OpenAPI spec into a mock server with realistic fake data
- API Contract Guardian — catch breaking API changes in CI before they reach consumers
For the getting-started guide covering key generation, listing, and basic verification, see API Key Management from the Terminal.