The Multi-Environment Rotation Problem
Rotating an API key in one place is easy. Rotating it everywhere is where teams break down:
- You rotate in the provider console. New key generated. You copy it.
- You update production. The service restarts. New key works.
- Staging breaks. Still has the old key. You forgot to update it.
- CI breaks. The GitHub Actions secret still has the old key. Tests fail.
- Dev environments break. Five developers have the old key in their
.envfiles. Nobody knows which ones. - The audit trail is empty. When was the key rotated? By whom? Which environments got the update? There's no record.
The problem isn't generating a new key — it's propagating it everywhere, verifying it works, and proving you did it. That requires two capabilities that no single tool provides:
- Multi-environment sync — push the new key to dev, staging, prod, and CI simultaneously
- Key versioning and audit — track which version is active, when it was rotated, and which services accepted it
That's why this workflow uses two tools: Envault for sync, APIAuth for versioning.
What Each Tool Does
| Capability | Envault | APIAuth |
|---|---|---|
| Generate API keys | ✗ (manages env vars, not key generation) | ✓ apiauth generate |
| Version key rotations | ✗ (rotates values in-place) | ✓ apiauth rotate increments version, stores previous hash |
| Verify a key value | ✗ | ✓ apiauth verify |
| Diff environments | ✓ envault diff dev prod |
✗ |
| Sync across environments | ✓ envault sync staging prod |
✗ |
| Rotate env vars by type | ✓ envault rotate DB_PASSWORD (smart type inference) |
✗ |
| Export for CI/CD | ✓ (env, dotenv, JSON) | ✓ (env, dotenv, JSON, GitHub Actions) |
| Audit trail | ✓ .envault-audit.log (operations log) |
✓ apiauth audit (expiry, revocation, version) |
| HTTP secrets API | ✓ envault serve (JSON API with Bearer auth) |
✗ |
| Encrypted keystore | ✓ (AES-256-GCM encrypted .env files) |
✓ (AES-256-GCM encrypted keystore) |
The key insight: Envault knows where secrets live (dev, staging, prod, CI). APIAuth knows what a key is (version, hash, expiry, rotation history). The workflow combines both: Envault propagates the new key value, APIAuth tracks the key lifecycle.
The Complete Workflow: 6 Steps, Zero Downtime
APIAuth Rotate the Key
Generate a new version of the API key. The old value is hashed out (not stored in plaintext), the version counter increments, and the rotation is timestamped.
# Rotate the key and capture the new value
apiauth rotate abc123def456 --expiry-days 90
# Output:
# ✓ Rotated abc123def456 (v2)
# New key: ak_QmZ4cF9nY3RrN2p1VHV4eQ
# Previous value has been hashed out. Save the new value.
At this point, the key entry has both the current hash (v2) and the previous hash (v1). Your service can accept both during the transition window.
Envault Check Environment Drift
Before propagating the new key, check if your environments are even in sync. If they're not, you have bigger problems — the old key might have different values in different places.
# Check if dev, staging, and prod have the same API key value
rh-envault diff dev staging
rh-envault diff staging prod
# If you see differences, investigate before rotating.
# The most dangerous scenario: prod has a different key than staging,
# which means your rotation might break a dependency you didn't know about.
Envault Propagate the New Key
Update the API key variable in your source environment, then sync it across all targets.
# Option A: Update the variable in staging, then sync to prod
rh-envault rotate STRIPE_API_KEY --env staging
# Envault's smart rotation infers the type:
# STRIPE_API_KEY → generates a prefixed API key automatically
# But we want the specific value from APIAuth, so:
# Option B: Set the new key value directly, then sync
export NEW_KEY="ak_QmZ4cF9nY3RrN2p1VHV4eQ"
rh-envault set STRIPE_API_KEY "$NEW_KEY" --env staging
# Sync staging → prod
rh-envault sync staging prod
# Sync staging → dev (if dev should have the same key)
rh-envault sync staging dev
Why sync instead of set-in-each? envault sync copies the exact value from one environment to another. If you set the key manually in each environment, a typo in one copy means that environment breaks silently. Sync guarantees bit-identical values.
Verify the New Key in Each Environment
Test that the new key works in every environment before removing the old key from your service's accepted list.
# Verify the new key value matches what APIAuth generated
apiauth verify ak_QmZ4cF9nY3RrN2p1VHV4eQ
# Test the new key against each environment
curl -H "Authorization: Bearer $NEW_KEY" https://api.staging.example.com/health
curl -H "Authorization: Bearer $NEW_KEY" https://api.example.com/health
# Confirm Envault has the same value in all environments
rh-envault diff dev prod
rh-envault diff staging prod
# Expected: no differences for STRIPE_API_KEY
Update CI/CD Secrets
Both tools can export for CI/CD, but they serve different purposes:
# APIAuth: export key metadata (IDs, services, expiry) for pipeline configuration
apiauth export --format github-actions
# This gives you key IDs and service names — NOT plaintext key values
# Envault: export actual secret values for CI pipeline consumption
rh-envault export --format dotenv --env prod > .env.ci
# This gives you the actual decrypted values for the CI environment
Security boundary: APIAuth never exports plaintext key values — they're shown only at generate and rotate time. Envault handles the actual values because its job is to put them where they're needed. Use APIAuth for "which keys exist and are they healthy?" Use Envault for "what's the current value in each environment?"
Complete the Rotation
Once the new key is confirmed working everywhere, remove the old key version from your service's accepted list. This is an application deploy, not a key management operation.
# In your application, remove the previous_hash check:
# Before: accept current_hash OR previous_hash
# After: accept current_hash only
# Run a final audit to confirm all keys are healthy
apiauth audit
# Expected: ✓ All N keys are healthy
# Check the Envault audit log for the rotation operation
rh-envault audit-log --filter "rotate" --env prod
# Shows: who rotated, when, which key, which environment
Full CI/CD Workflow: Automated Multi-Environment Rotation
Here's a GitHub Actions workflow that rotates a key with APIAuth, syncs it across environments with Envault, and verifies it — all automatically.
name: Rotate API Key Across Environments
on:
schedule:
- cron: '0 9 1 */3 *' # Every 90 days
workflow_dispatch:
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
pip install apiauth
pip install git+https://github.com/Coding-Dev-Tools/envault.git
- name: Check environment drift before rotation
env:
ENVAULT_MASTER_KEY: ${{ secrets.ENVAULT_MASTER_KEY }}
run: |
# If environments are out of sync, stop before rotating
rh-envault diff staging prod > drift-report.txt 2>&1
cat drift-report.txt
if grep -q "different values" drift-report.txt; then
echo "::warning::Environments are out of sync — investigate before rotating"
fi
- name: Rotate key with APIAuth
id: rotate
env:
APIAUTH_MASTER_KEY: ${{ secrets.APIAUTH_MASTER_KEY }}
run: |
OUTPUT=$(apiauth rotate ${{ secrets.API_KEY_ID }} --expiry-days 90 2>&1)
echo "$OUTPUT"
NEW_KEY=$(echo "$OUTPUT" | grep "New key:" | awk '{print $3}')
echo "new_key=$NEW_KEY" >> "$GITHUB_OUTPUT"
- name: Propagate new key with Envault
env:
ENVAULT_MASTER_KEY: ${{ secrets.ENVAULT_MASTER_KEY }}
run: |
# Set the new key in staging first
rh-envault set STRIPE_API_KEY "${{ steps.rotate.outputs.new_key }}" --env staging
# Sync staging → prod (bit-identical copy)
rh-envault sync staging prod
# Sync staging → dev
rh-envault sync staging dev
- name: Verify environments are in sync
env:
ENVAULT_MASTER_KEY: ${{ secrets.ENVAULT_MASTER_KEY }}
run: |
# No differences = successful sync
rh-envault diff dev staging
rh-envault diff staging prod
- name: Verify new key with APIAuth
env:
APIAUTH_MASTER_KEY: ${{ secrets.APIAUTH_MASTER_KEY }}
run: |
apiauth verify ${{ steps.rotate.outputs.new_key }}
apiauth audit
- name: Test new key against staging
run: |
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${{ steps.rotate.outputs.new_key }}" \
https://api.staging.example.com/health)
if [ "$HTTP_STATUS" != "200" ]; then
echo "ERROR: New key failed on staging (HTTP $HTTP_STATUS)"
exit 1
fi
- name: Test new key against production
run: |
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 failed on production (HTTP $HTTP_STATUS)"
exit 1
fi
- name: Update GitHub Actions secret
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
run: |
gh secret set STRIPE_API_KEY \
--body "${{ steps.rotate.outputs.new_key }}" \
--repo ${{ github.repository }}
- name: Final audit
env:
APIAUTH_MASTER_KEY: ${{ secrets.APIAUTH_MASTER_KEY }}
ENVAULT_MASTER_KEY: ${{ secrets.ENVAULT_MASTER_KEY }}
run: |
echo "=== APIAuth Audit ==="
apiauth audit
echo ""
echo "=== Envault Audit Log (last 24h) ==="
rh-envault audit-log --since 24h
- name: Alert on failure
if: failure()
run: |
echo "✗ Key rotation failed — old key still valid, investigate immediately"
Envault's Smart Rotation vs APIAuth's Versioned Rotation
Both tools can rotate secrets, but they do different things:
| Feature | envault rotate |
apiauth rotate |
|---|---|---|
| What it generates | Environment variable value (inferred type) | API key or JWT (explicit type) |
| Type inference | ✓ DB_PASSWORD → safe chars, JWT_SECRET → base64, WEBHOOK_SECRET → hex |
✗ (always generates ak_-prefixed key) |
| Version tracking | ✗ (in-place replacement) | ✓ (version counter, previous hash, rotated_at) |
| Dual-key transition | ✗ | ✓ (previous_hash enables v1 + v2 acceptance) |
| Multi-environment sync | ✓ (sync staging → prod) | ✗ |
| Dry run | ✓ --dry-run --show |
✗ |
| Best for | Database passwords, JWT secrets, webhook secrets — generic env vars | API keys with consumers that verify hashes — Stripe keys, service-to-service keys |
When to use which: If the secret is an API key that external services verify against, use APIAuth for the rotation (versioning + audit). If the secret is a database password or internal token that only your application reads, use Envault's rotate (simpler, smart type inference). For the full workflow, use both: APIAuth for key generation + versioning, Envault for multi-environment propagation.
Using the Envault HTTP API for Runtime Secret Loading
If your services load secrets at runtime instead of from .env files, use envault serve to expose decrypted secrets over HTTP:
# Start the secrets API (localhost only by default)
rh-envault serve --port 8080 --api-key my-bearer-token
# Your service fetches the latest key at startup
curl -H "Authorization: Bearer my-bearer-token" http://localhost:8080/secrets/STRIPE_API_KEY
# Filter by prefix for related secrets
curl -H "Authorization: Bearer my-bearer-token" "http://localhost:8080/secrets?prefix=STRIPE_"
This is especially useful for MCP server sidecars and AI agent runtimes that need fresh secrets without restarting. After a rotation, the next request to /secrets/STRIPE_API_KEY returns the new value automatically.
Combine with APIAuth verify: After your service fetches a key from the Envault HTTP API, run apiauth verify to confirm it matches the current version. This catches the scenario where Envault has a stale cached value — the verify call will fail if the key doesn't match APIAuth's current hash.
Audit Trail: Two Perspectives, One Timeline
When your auditor asks "prove this key was rotated and deployed correctly," you need both tools' records:
APIAuth: Key Lifecycle Audit
apiauth show abc123def456
# Shows:
# - version: 2 (was rotated)
# - rotated_at: 2026-05-27T09:00:00Z (when)
# - previous_hash: sha256:... (proof old value was hashed out)
# - expires_at: 2026-08-27T12:00:00Z (next rotation due)
# - revoked: false (still active)
Envault: Operations Audit
rh-envault audit-log --filter "rotate" --since 7d
# Shows:
# [2026-05-27 09:00:15] ROTATE STRIPE_API_KEY env=staging user=ci-bot
# [2026-05-27 09:00:22] SYNC staging → prod user=ci-bot
# [2026-05-27 09:00:28] SYNC staging → dev user=ci-bot
# [2026-05-27 09:00:35] SET STRIPE_API_KEY env=ci value=*** user=ci-bot
Together, these answer the auditor's five questions:
- Was the key rotated? APIAuth:
rotated_at+version: 2 - When? Both timestamps agree: 2026-05-27T09:00:00Z
- Was the old value destroyed? APIAuth:
previous_hashexists (old value hashed, not stored as plaintext) - Was the new key deployed everywhere? Envault: sync operations for staging → prod, staging → dev
- Is the key still healthy? APIAuth:
apiauth audit→ all keys healthy
When Things Go Wrong
The new key doesn't work in production
If you used APIAuth's versioned rotation, the old key (v1) is still accepted via the previous_hash check. Your service doesn't break — it logs a warning about the deprecated key version. Fix the new key deployment, then re-verify.
Envault sync fails mid-operation
If envault sync staging prod fails, staging has the new key but prod doesn't. This is safe — your service still accepts v1. Re-run the sync after fixing the connectivity issue.
You accidentally revoke instead of rotate
apiauth revoke marks the entire key entry as revoked. Both v1 and v2 become invalid. You need to generate a completely new key with apiauth generate and propagate it with Envault from scratch. This is why the CI/CD workflow uses rotate and never revoke.
Environments drift before rotation
If envault diff dev prod shows differences before you start the rotation, investigate first. The safest approach: sync all environments to match prod, verify everything works, then rotate. Rotating on top of drift means you're deploying a new key into environments that were already broken.
Install Both Tools
# pip
pip install apiauth
pip install git+https://github.com/Coding-Dev-Tools/envault.git
# Homebrew (macOS / Linux)
brew tap Coding-Dev-Tools/tap
brew install apiauth envault
# Scoop (Windows)
scoop bucket add Coding-Dev-Tools https://github.com/Coding-Dev-Tools/scoop-bucket
scoop install apiauth envault
Star Envault on GitHub
Star APIAuth on GitHub
Related Reading
- Zero-Downtime API Key Rotation in CI/CD — APIAuth's versioned rotation in depth
- Envault Serve: HTTP API for Your Secrets — the
envault servecommand in detail - API Key Management from the Terminal — APIAuth getting-started guide
- Sync Environment Variables Across Environments — Envault getting-started guide
- Block Deployments on Config Drift — catch drift before it reaches production