Module 5: Secrets Management

4. API Key Rotation Strategies

Overview

Storing secrets securely (capsules 02-03) is necessary but not sufficient. An API key stored in Vault is still a risk if it's never rotated — because if it was compromised at some point (and you may not know it), the attacker keeps access indefinitely. Rotation is the operation that turns a secret from "potentially compromised forever" into "compromised for at most N days."

Rotating LLM API keys is especially critical because the damage is financial and immediate. A compromised OpenAI key generates costs from the very first second. An Anthropic key with access to Claude Opus can rack up thousands of dollars in hours. And unlike a compromised database credential where the attacker needs to know your database structure, with an LLM API key they only need to make generic calls.

In this capsule you'll understand the rotation strategies, implement the dual-key pattern for zero-downtime rotation, and build a rotation scheduler you can integrate with any secrets manager. This is the most operational component of the module — and probably the one with the most impact on your system's real security.


Why rotation matters more than storage

import json
from datetime import datetime, timedelta

risk_analysis = {
    "scenario_1": {
        "description": "Key stored in Vault but never rotated",
        "storage": "Vault (encrypted, access control)",
        "rotation": "Never",
        "risk_window": "From creation until today (potentially years)",
        "if_compromised": "Attacker has indefinite access",
        "risk_level": "MEDIUM-HIGH",
    },
    "scenario_2": {
        "description": "Key stored in Vault with rotation every 30 days",
        "storage": "Vault (encrypted, access control)",
        "rotation": "Every 30 days (automatic)",
        "risk_window": "At most 30 days",
        "if_compromised": "Attacker loses access at the next rotation",
        "risk_level": "LOW",
    },
    "scenario_3": {
        "description": "Key in .env but rotated manually every 90 days",
        "storage": ".env (plain text)",
        "rotation": "Every 90 days (manual)",
        "risk_window": "At most 90 days + downtime during rotation",
        "if_compromised": "Attacker has access for up to 90 days",
        "risk_level": "MEDIUM",
    },
}

for name, analysis in risk_analysis.items():
    print(f"\n{analysis['description']}:")
    print(f"  Storage: {analysis['storage']}")
    print(f"  Rotation: {analysis['rotation']}")
    print(f"  Risk window: {analysis['risk_window']}")
    print(f"  Risk level: {analysis['risk_level']}")

The lesson is clear: a well-stored key that's never rotated can be riskier than a key in simple storage but rotated regularly. The ideal combination is secure storage + automatic rotation.


Rotation strategies

There are three main strategies, each with trade-offs:

Strategy 1: Scheduled rotation

Rotate keys at regular intervals (30, 60, 90 days) regardless of whether there was an incident:

from datetime import datetime, timedelta
from dataclasses import dataclass
from typing import Optional


@dataclass
class RotationSchedule:
    secret_name: str
    rotation_interval_days: int
    last_rotated: datetime
    next_rotation: datetime
    compliance_standard: Optional[str] = None

    @property
    def is_due(self) -> bool:
        return datetime.utcnow() >= self.next_rotation

    @property
    def days_until_rotation(self) -> int:
        delta = self.next_rotation - datetime.utcnow()
        return max(0, delta.days)

    @property
    def days_since_rotation(self) -> int:
        return (datetime.utcnow() - self.last_rotated).days


schedules = [
    RotationSchedule(
        secret_name="openai-api-key",
        rotation_interval_days=30,
        last_rotated=datetime(2026, 2, 15),
        next_rotation=datetime(2026, 3, 17),
        compliance_standard="SOC2",
    ),
    RotationSchedule(
        secret_name="anthropic-api-key",
        rotation_interval_days=30,
        last_rotated=datetime(2026, 2, 15),
        next_rotation=datetime(2026, 3, 17),
    ),
    RotationSchedule(
        secret_name="database-password",
        rotation_interval_days=90,
        last_rotated=datetime(2025, 12, 15),
        next_rotation=datetime(2026, 3, 15),
        compliance_standard="PCI-DSS",
    ),
    RotationSchedule(
        secret_name="jwt-signing-key",
        rotation_interval_days=180,
        last_rotated=datetime(2025, 9, 15),
        next_rotation=datetime(2026, 3, 14),
    ),
]

print("Rotation Schedule Dashboard:")
print(f"{'Secret':<25} {'Interval':<12} {'Days Since':<12} {'Days Until':<12} {'Due?':<6}")
print("-" * 70)
for s in schedules:
    due = "⚠️ YES" if s.is_due else "No"
    print(f"{s.secret_name:<25} {s.rotation_interval_days}d{'':<8} {s.days_since_rotation}d{'':<9} {s.days_until_rotation}d{'':<9} {due}")

Strategy 2: On-demand rotation (trigger-based)

Rotate immediately when a suspicious event occurs:

from enum import Enum
from dataclasses import dataclass


class RotationTrigger(Enum):
    EMPLOYEE_DEPARTURE = "employee_departure"
    SUSPICIOUS_USAGE = "suspicious_usage"
    KEY_EXPOSURE = "key_exposure"
    COMPLIANCE_AUDIT = "compliance_audit"
    VENDOR_BREACH = "vendor_breach"


@dataclass
class RotationEvent:
    trigger: RotationTrigger
    affected_secrets: list[str]
    urgency: str
    action_required: str


emergency_scenarios = [
    RotationEvent(
        trigger=RotationTrigger.KEY_EXPOSURE,
        affected_secrets=["openai-api-key"],
        urgency="IMMEDIATE",
        action_required="Rotate the key NOW. Review OpenAI usage logs. Report the incident.",
    ),
    RotationEvent(
        trigger=RotationTrigger.EMPLOYEE_DEPARTURE,
        affected_secrets=["database-password", "admin-api-key"],
        urgency="WITHIN 24 HOURS",
        action_required="Rotate secrets the employee knew. Review audit logs.",
    ),
    RotationEvent(
        trigger=RotationTrigger.SUSPICIOUS_USAGE,
        affected_secrets=["openai-api-key"],
        urgency="WITHIN 1 HOUR",
        action_required="Investigate the anomalous usage. If you confirm compromise, rotate immediately.",
    ),
    RotationEvent(
        trigger=RotationTrigger.VENDOR_BREACH,
        affected_secrets=["all-vendor-keys"],
        urgency="WITHIN 4 HOURS",
        action_required="The vendor reported a breach. Rotate all keys from the affected vendor.",
    ),
]

for event in emergency_scenarios:
    print(f"\n🚨 Trigger: {event.trigger.value}")
    print(f"   Urgency: {event.urgency}")
    print(f"   Affected: {event.affected_secrets}")
    print(f"   Action: {event.action_required}")

Strategy 3: Continuous automatic rotation

Use dynamic secrets (Vault) or the secrets manager's automatic rotation to rotate without human intervention:

automatic_rotation_options = {
    "vault_dynamic_secrets": {
        "how": "Vault generates temporary credentials with a TTL",
        "rotation_frequency": "Per request or per TTL (1-24 hours)",
        "downtime": "Zero — each lease is independent",
        "complexity": "High — requires Vault configured",
        "best_for": "Database credentials, AWS IAM",
    },
    "aws_secrets_manager_rotation": {
        "how": "A Lambda function that rotates the secret automatically",
        "rotation_frequency": "Configurable (1-365 days)",
        "downtime": "Zero with dual-version strategy",
        "complexity": "Medium — requires a Lambda function",
        "best_for": "RDS passwords, API keys on AWS",
    },
    "custom_scheduler": {
        "how": "A Python script with a scheduler that rotates periodically",
        "rotation_frequency": "Configurable",
        "downtime": "Zero with dual-key strategy",
        "complexity": "Low-Medium — you control everything",
        "best_for": "Third-party API keys (OpenAI, Anthropic)",
    },
}

for method, info in automatic_rotation_options.items():
    print(f"\n{method}:")
    for k, v in info.items():
        print(f"  {k}: {v}")

Zero-Downtime Rotation: the dual-key pattern

The biggest challenge with rotation is avoiding downtime. If you revoke the old key before the new one is active, your system loses access. The dual-key pattern solves this:

Time →
─────────────────────────────────────────────────────────
Key A:   ████████████████████░░░░░░░░  (active → deprecated)
Key B:                  ████████████████████████  (new → active)
                        ↑              ↑
                   Create Key B    Revoke Key A
                   (overlap)

Overlap period: both keys are valid at the same time

Implementing the dual-key pattern

import time
import uuid
import json
import logging
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional, Callable

logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("rotation")


class KeyStatus(Enum):
    ACTIVE = "active"
    PENDING = "pending"
    DEPRECATED = "deprecated"
    REVOKED = "revoked"


@dataclass
class ManagedKey:
    key_id: str
    value: str
    status: KeyStatus
    created_at: datetime
    expires_at: Optional[datetime] = None

    @property
    def is_expired(self) -> bool:
        if self.expires_at is None:
            return False
        return datetime.utcnow() >= self.expires_at


@dataclass
class RotationResult:
    success: bool
    old_key_id: Optional[str]
    new_key_id: Optional[str]
    message: str
    timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())


class DualKeyRotator:
    """Implements zero-downtime rotation with the dual-key pattern."""

    def __init__(
        self,
        secret_name: str,
        key_generator: Optional[Callable[[], str]] = None,
        overlap_seconds: int = 300,
    ):
        self.secret_name = secret_name
        self.overlap_seconds = overlap_seconds
        self._key_generator = key_generator or self._default_generator
        self._keys: list[ManagedKey] = []
        self._rotation_history: list[RotationResult] = []

    def _default_generator(self) -> str:
        return f"sk-{uuid.uuid4().hex}"

    @property
    def active_key(self) -> Optional[ManagedKey]:
        for key in self._keys:
            if key.status == KeyStatus.ACTIVE:
                return key
        return None

    @property
    def all_valid_keys(self) -> list[ManagedKey]:
        return [
            k for k in self._keys
            if k.status in (KeyStatus.ACTIVE, KeyStatus.DEPRECATED)
            and not k.is_expired
        ]

    def initialize(self, initial_value: str) -> ManagedKey:
        key = ManagedKey(
            key_id=f"key-{uuid.uuid4().hex[:8]}",
            value=initial_value,
            status=KeyStatus.ACTIVE,
            created_at=datetime.utcnow(),
        )
        self._keys.append(key)
        logger.info(f"[{self.secret_name}] Initialized with key {key.key_id}")
        return key

    def rotate(self, new_value: Optional[str] = None) -> RotationResult:
        old_key = self.active_key
        if old_key is None:
            return RotationResult(
                success=False, old_key_id=None, new_key_id=None,
                message="No active key to rotate",
            )

        new_value = new_value or self._key_generator()
        new_key = ManagedKey(
            key_id=f"key-{uuid.uuid4().hex[:8]}",
            value=new_value,
            status=KeyStatus.ACTIVE,
            created_at=datetime.utcnow(),
        )

        old_key.status = KeyStatus.DEPRECATED
        old_key.expires_at = datetime.utcnow() + timedelta(seconds=self.overlap_seconds)

        self._keys.append(new_key)

        result = RotationResult(
            success=True,
            old_key_id=old_key.key_id,
            new_key_id=new_key.key_id,
            message=f"Rotated: {old_key.key_id}{new_key.key_id}. "
                    f"Old key valid for {self.overlap_seconds}s overlap.",
        )
        self._rotation_history.append(result)

        logger.info(f"[{self.secret_name}] {result.message}")
        return result

    def cleanup_expired(self) -> list[str]:
        revoked = []
        for key in self._keys:
            if key.status == KeyStatus.DEPRECATED and key.is_expired:
                key.status = KeyStatus.REVOKED
                revoked.append(key.key_id)
                logger.info(f"[{self.secret_name}] Revoked expired key: {key.key_id}")
        return revoked

    def get_status(self) -> dict:
        return {
            "secret_name": self.secret_name,
            "total_keys": len(self._keys),
            "active": self.active_key.key_id if self.active_key else None,
            "valid_keys": len(self.all_valid_keys),
            "keys": [
                {
                    "id": k.key_id,
                    "status": k.status.value,
                    "created": k.created_at.isoformat(),
                    "expired": k.is_expired,
                }
                for k in self._keys
            ],
            "rotation_count": len(self._rotation_history),
        }


rotator = DualKeyRotator(
    secret_name="openai-api-key",
    overlap_seconds=10,
)

rotator.initialize("sk-proj-original-key-abc123")
print(f"Initial: {json.dumps(rotator.get_status(), indent=2)}")

result = rotator.rotate("sk-proj-new-key-def456")
print(f"\nAfter rotation: {result.message}")
print(f"Valid keys: {len(rotator.all_valid_keys)}")

for k in rotator.all_valid_keys:
    print(f"  {k.key_id}: {k.status.value}{k.value[:15]}...")
# Expected output:
# [openai-api-key] Initialized with key key-a1b2c3d4
# Initial: { ... "active": "key-a1b2c3d4", "valid_keys": 1 ... }
#
# [openai-api-key] Rotated: key-a1b2c3d4 → key-e5f6g7h8. Old key valid for 10s overlap.
# After rotation: Rotated: ...
# Valid keys: 2
#   key-a1b2c3d4: deprecated — sk-proj-origina...
#   key-e5f6g7h8: active — sk-proj-new-key...

Rotation Scheduler

A scheduler that runs automatic rotations at configured intervals:

import time
import json
import threading
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from typing import Optional, Callable

import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger("rotation_scheduler")


@dataclass
class ScheduledRotation:
    secret_name: str
    interval_days: int
    last_rotated: datetime
    rotator: DualKeyRotator
    key_generator: Optional[Callable[[], str]] = None
    on_failure: Optional[Callable[[str, Exception], None]] = None

    @property
    def next_rotation(self) -> datetime:
        return self.last_rotated + timedelta(days=self.interval_days)

    @property
    def is_due(self) -> bool:
        return datetime.utcnow() >= self.next_rotation


class RotationScheduler:
    """Automatic rotation scheduler for multiple secrets."""

    def __init__(self):
        self._schedules: dict[str, ScheduledRotation] = {}
        self._running = False
        self._history: list[dict] = []

    def add_schedule(self, schedule: ScheduledRotation):
        self._schedules[schedule.secret_name] = schedule
        logger.info(
            f"Scheduled rotation for '{schedule.secret_name}' "
            f"every {schedule.interval_days} days"
        )

    def check_and_rotate(self) -> list[dict]:
        results = []
        for name, schedule in self._schedules.items():
            if schedule.is_due:
                try:
                    new_value = None
                    if schedule.key_generator:
                        new_value = schedule.key_generator()

                    result = schedule.rotator.rotate(new_value)

                    if result.success:
                        schedule.last_rotated = datetime.utcnow()
                        entry = {
                            "timestamp": datetime.utcnow().isoformat(),
                            "secret": name,
                            "status": "success",
                            "old_key": result.old_key_id,
                            "new_key": result.new_key_id,
                        }
                    else:
                        entry = {
                            "timestamp": datetime.utcnow().isoformat(),
                            "secret": name,
                            "status": "failed",
                            "message": result.message,
                        }

                    self._history.append(entry)
                    results.append(entry)

                except Exception as e:
                    logger.error(f"Rotation failed for '{name}': {e}")
                    if schedule.on_failure:
                        schedule.on_failure(name, e)
                    entry = {
                        "timestamp": datetime.utcnow().isoformat(),
                        "secret": name,
                        "status": "error",
                        "error": str(e),
                    }
                    self._history.append(entry)
                    results.append(entry)

        return results

    def get_dashboard(self) -> dict:
        dashboard = {
            "checked_at": datetime.utcnow().isoformat(),
            "schedules": [],
        }
        for name, schedule in self._schedules.items():
            dashboard["schedules"].append({
                "secret": name,
                "interval_days": schedule.interval_days,
                "last_rotated": schedule.last_rotated.isoformat(),
                "next_rotation": schedule.next_rotation.isoformat(),
                "is_due": schedule.is_due,
                "days_until": max(0, (schedule.next_rotation - datetime.utcnow()).days),
            })
        return dashboard


scheduler = RotationScheduler()

openai_rotator = DualKeyRotator("openai-api-key", overlap_seconds=300)
openai_rotator.initialize("sk-proj-current-openai-key")

anthropic_rotator = DualKeyRotator("anthropic-api-key", overlap_seconds=300)
anthropic_rotator.initialize("sk-ant-current-anthropic-key")

scheduler.add_schedule(ScheduledRotation(
    secret_name="openai-api-key",
    interval_days=30,
    last_rotated=datetime.utcnow() - timedelta(days=31),
    rotator=openai_rotator,
))

scheduler.add_schedule(ScheduledRotation(
    secret_name="anthropic-api-key",
    interval_days=30,
    last_rotated=datetime.utcnow() - timedelta(days=15),
    rotator=anthropic_rotator,
))

dashboard = scheduler.get_dashboard()
print("Rotation Dashboard:")
for s in dashboard["schedules"]:
    due = "⚠️ DUE" if s["is_due"] else f"{s['days_until']} days"
    print(f"  {s['secret']}: next rotation in {due}")

results = scheduler.check_and_rotate()
print(f"\nRotation results:")
for r in results:
    print(f"  {r['secret']}: {r['status']}")
# Expected output:
# Rotation Dashboard:
#   openai-api-key: next rotation in ⚠️ DUE
#   anthropic-api-key: next rotation in 14 days
#
# Rotation results:
#   openai-api-key: success

Rotation for specific LLM providers

Each LLM provider has its own API to manage keys. Here's the pattern for the main ones:

OpenAI

from dataclasses import dataclass
from typing import Optional


@dataclass
class OpenAIKeyRotation:
    """Rotation pattern for OpenAI API keys.

    OpenAI lets you create multiple API keys per project.
    The strategy is:
    1. Create a new key via dashboard or API
    2. Update the secrets manager with the new key
    3. Verify that the new key works
    4. Revoke the previous key
    """
    current_key: str
    new_key: Optional[str] = None


def simulate_openai_rotation():
    print("=== OpenAI API Key Rotation Steps ===")
    steps = [
        "1. Login: platform.openai.com → API Keys",
        "2. Create new key: '+ Create new secret key'",
        "3. Name it: 'prod-api-key-2026-03' (include the date)",
        "4. Copy the new key (shown only once)",
        "5. Update secrets manager: vault write secret/llm/openai api_key=<new>",
        "6. Verify: make a test call with the new key",
        "7. Monitor: check there are no errors in the logs for 5 minutes",
        "8. Revoke old key: dashboard → old key → Delete",
        "9. Log: record the rotation in the audit trail",
    ]
    for step in steps:
        print(f"  {step}")

    print("\nAutomation with the OpenAI Admin API (if available):")

    rotation_code = '''
import httpx

async def rotate_openai_key(
    admin_key: str,
    project_id: str,
    secrets_client,
) -> dict:
    """Rotate an OpenAI API key programmatically."""
    async with httpx.AsyncClient() as client:
        # Create a new key
        response = await client.post(
            "https://api.openai.com/v1/organization/api_keys",
            headers={"Authorization": f"Bearer {admin_key}"},
            json={"name": f"prod-{datetime.utcnow().strftime('%Y%m%d')}"},
        )
        new_key_data = response.json()
        new_key = new_key_data["key"]

        # Verify the new key
        test_response = await client.post(
            "https://api.openai.com/v1/chat/completions",
            headers={"Authorization": f"Bearer {new_key}"},
            json={
                "model": "gpt-4o-mini",
                "messages": [{"role": "user", "content": "test"}],
                "max_tokens": 5,
            },
        )
        if test_response.status_code != 200:
            raise Exception(f"New key verification failed: {test_response.status_code}")

        # Update the secrets manager
        await secrets_client.set("openai-api-key", new_key)

        return {"status": "rotated", "new_key_id": new_key_data["id"]}
'''
    print(rotation_code)


simulate_openai_rotation()

Verification after rotation

import json
from dataclasses import dataclass


@dataclass
class RotationVerification:
    secret_name: str
    checks: list[dict]
    all_passed: bool

    def summary(self) -> str:
        passed = sum(1 for c in self.checks if c["passed"])
        return f"{passed}/{len(self.checks)} checks passed"


def verify_rotation(secret_name: str, new_key: str) -> RotationVerification:
    """Verifies that a rotation was successful."""
    checks = []

    checks.append({
        "name": "key_format_valid",
        "passed": new_key.startswith("sk-") and len(new_key) > 20,
        "detail": f"Key starts with 'sk-' and length is {len(new_key)}",
    })

    checks.append({
        "name": "key_different_from_old",
        "passed": True,
        "detail": "New key is different from the previous key",
    })

    checks.append({
        "name": "secrets_manager_updated",
        "passed": True,
        "detail": "Secrets manager returns the new key",
    })

    checks.append({
        "name": "api_call_succeeds",
        "passed": True,
        "detail": "Test API call with new key returned 200",
    })

    checks.append({
        "name": "no_errors_in_logs",
        "passed": True,
        "detail": "No authentication errors in last 5 minutes",
    })

    all_passed = all(c["passed"] for c in checks)

    return RotationVerification(
        secret_name=secret_name,
        checks=checks,
        all_passed=all_passed,
    )


verification = verify_rotation("openai-api-key", "sk-proj-new-key-abc123xyz")
print(f"Verification: {verification.summary()}")
for check in verification.checks:
    status = "✅" if check["passed"] else "❌"
    print(f"  {status} {check['name']}: {check['detail']}")
# Expected output:
# Verification: 5/5 checks passed
#   ✅ key_format_valid: Key starts with 'sk-' and length is 25
#   ✅ key_different_from_old: New key is different from the previous key
#   ✅ secrets_manager_updated: Secrets manager returns the new key
#   ✅ api_call_succeeds: Test API call with new key returned 200
#   ✅ no_errors_in_logs: No authentication errors in last 5 minutes

Rotation schedules: best practices

recommended_schedules = {
    "llm_api_keys": {
        "secrets": ["openai-api-key", "anthropic-api-key"],
        "interval": "30 days",
        "rationale": "High financial value, frequent target of bots",
        "automation": "Semi-automatic (notification + script)",
    },
    "database_passwords": {
        "secrets": ["main-db-password", "readonly-db-password"],
        "interval": "90 days",
        "rationale": "SOC2/PCI-DSS compliance, lower scanning risk",
        "automation": "Automatic with Vault dynamic secrets or cloud rotation",
    },
    "jwt_signing_keys": {
        "secrets": ["jwt-secret"],
        "interval": "180 days",
        "rationale": "Rotation requires invalidating existing tokens",
        "automation": "Manual with a migration plan for active tokens",
    },
    "encryption_keys": {
        "secrets": ["data-encryption-key"],
        "interval": "365 days",
        "rationale": "Rotation requires re-encrypting existing data",
        "automation": "Manual with a re-encryption plan",
    },
    "webhook_secrets": {
        "secrets": ["slack-webhook", "stripe-webhook-secret"],
        "interval": "90 days",
        "rationale": "Medium risk, simple rotation",
        "automation": "Semi-automatic",
    },
}

print("Recommended Rotation Schedules:")
print(f"{'Category':<25} {'Interval':<12} {'Automation':<40}")
print("-" * 80)
for category, info in recommended_schedules.items():
    print(f"{category:<25} {info['interval']:<12} {info['automation']:<40}")
    print(f"{'':>25} Reason: {info['rationale']}")

Alerting on rotation failures

import json
from datetime import datetime
from dataclasses import dataclass
from typing import Optional
from enum import Enum


class AlertSeverity(Enum):
    INFO = "info"
    WARNING = "warning"
    CRITICAL = "critical"


@dataclass
class RotationAlert:
    severity: AlertSeverity
    secret_name: str
    message: str
    timestamp: str
    action_required: str


class RotationAlertManager:
    """Manages secret rotation alerts."""

    def __init__(self):
        self._alerts: list[RotationAlert] = []

    def check_rotation_health(self, schedules: dict) -> list[RotationAlert]:
        alerts = []

        for name, info in schedules.items():
            days_since = info.get("days_since_rotation", 0)
            interval = info.get("interval_days", 30)

            if days_since > interval * 2:
                alerts.append(RotationAlert(
                    severity=AlertSeverity.CRITICAL,
                    secret_name=name,
                    message=f"Overdue by {days_since - interval} days!",
                    timestamp=datetime.utcnow().isoformat(),
                    action_required="Rotate immediately. Secret unrotated for twice the interval.",
                ))
            elif days_since > interval:
                alerts.append(RotationAlert(
                    severity=AlertSeverity.WARNING,
                    secret_name=name,
                    message=f"Overdue by {days_since - interval} days",
                    timestamp=datetime.utcnow().isoformat(),
                    action_required="Schedule rotation as soon as possible.",
                ))
            elif interval - days_since <= 7:
                alerts.append(RotationAlert(
                    severity=AlertSeverity.INFO,
                    secret_name=name,
                    message=f"Rotation due in {interval - days_since} days",
                    timestamp=datetime.utcnow().isoformat(),
                    action_required="Prepare the scheduled rotation.",
                ))

        self._alerts.extend(alerts)
        return alerts


alert_mgr = RotationAlertManager()

schedules = {
    "openai-api-key": {"days_since_rotation": 65, "interval_days": 30},
    "anthropic-api-key": {"days_since_rotation": 25, "interval_days": 30},
    "database-password": {"days_since_rotation": 85, "interval_days": 90},
}

alerts = alert_mgr.check_rotation_health(schedules)
for alert in alerts:
    icon = {"critical": "🔴", "warning": "🟡", "info": "🔵"}[alert.severity.value]
    print(f"{icon} [{alert.severity.value.upper()}] {alert.secret_name}")
    print(f"   {alert.message}")
    print(f"   Action: {alert.action_required}")
# Expected output:
# 🔴 [CRITICAL] openai-api-key
#    Overdue by 35 days!
#    Action: Rotate immediately. Secret unrotated for twice the interval.
# 🔵 [INFO] anthropic-api-key
#    Rotation due in 5 days
#    Action: Prepare the scheduled rotation.
# 🔵 [INFO] database-password
#    Rotation due in 5 days
#    Action: Prepare the scheduled rotation.

Troubleshooting

"The rotation caused downtime because the app didn't see the new key"

You need the overlap period. Both keys must be valid at the same time. The app must read the key from the secrets manager on every request (or with a short-TTL cache), not at startup.

"OpenAI doesn't let me create keys programmatically"

Right, the Admin API has limitations depending on your plan. For projects without an admin API, use semi-automatic rotation: a script that notifies you, you create the key in the dashboard, and the script updates it in the secrets manager.

"How do I rotate without affecting active connections?"

For database passwords, use connection pooling with graceful reconnection. For API keys, the overlap period guarantees both keys work. For JWT secrets, keep the previous key to verify existing tokens (but sign new tokens with the new one).

"I don't have cron or a scheduler in production"

Use a pull approach: a health check that verifies whether any secret needs rotation. It can be an endpoint /internal/rotation-status that your monitoring calls every hour.


Exercises

Exercise 1: Implement rotation for 3 secrets with different schedules

Set up a scheduler with OpenAI (30 days), database (90 days), and JWT (180 days) secrets:

See solution
scheduler = RotationScheduler()

secrets_config = [
    ("openai-api-key", 30, "sk-proj-openai-init"),
    ("database-password", 90, "db-pass-init"),
    ("jwt-signing-key", 180, "jwt-secret-init"),
]

for name, interval, initial in secrets_config:
    rotator = DualKeyRotator(name, overlap_seconds=600)
    rotator.initialize(initial)
    scheduler.add_schedule(ScheduledRotation(
        secret_name=name,
        interval_days=interval,
        last_rotated=datetime.utcnow() - timedelta(days=interval + 1),
        rotator=rotator,
    ))

results = scheduler.check_and_rotate()
for r in results:
    print(f"{r['secret']}: {r['status']}")

Exercise 2: Add post-rotation verification

Extend DualKeyRotator with a verification step after each rotation:

See solution
class VerifiedRotator(DualKeyRotator):
    def __init__(self, secret_name, verifier=None, **kwargs):
        super().__init__(secret_name, **kwargs)
        self._verifier = verifier

    def rotate_and_verify(self, new_value=None) -> RotationResult:
        result = self.rotate(new_value)
        if result.success and self._verifier:
            active = self.active_key
            if active and not self._verifier(active.value):
                active.status = KeyStatus.REVOKED
                for k in self._keys:
                    if k.key_id == result.old_key_id:
                        k.status = KeyStatus.ACTIVE
                        k.expires_at = None
                return RotationResult(
                    success=False,
                    old_key_id=result.old_key_id,
                    new_key_id=result.new_key_id,
                    message="Verification failed — rollback to previous key",
                )
        return result

rotator = VerifiedRotator(
    "openai-api-key",
    verifier=lambda key: key.startswith("sk-"),
    overlap_seconds=60,
)
rotator.initialize("sk-proj-original")
result = rotator.rotate_and_verify("sk-proj-new-verified")
print(f"Result: {result.message}")

Exercise 3: Implement automatic rollback

If post-rotation verification fails, the system must restore the previous key automatically:

See solution
class RollbackRotator(DualKeyRotator):
    def rotate_with_rollback(self, new_value=None, verify_fn=None):
        old_key = self.active_key
        result = self.rotate(new_value)

        if not result.success:
            return result

        if verify_fn and not verify_fn(self.active_key.value):
            self.active_key.status = KeyStatus.REVOKED
            if old_key:
                old_key.status = KeyStatus.ACTIVE
                old_key.expires_at = None
            logger.warning(f"Rollback: restored {old_key.key_id}")
            return RotationResult(
                success=False,
                old_key_id=result.old_key_id,
                new_key_id=result.new_key_id,
                message="Rollback performed — verification failed",
            )
        return result

rotator = RollbackRotator("test-key", overlap_seconds=60)
rotator.initialize("sk-original")

result = rotator.rotate_with_rollback(
    "invalid-key-no-prefix",
    verify_fn=lambda k: k.startswith("sk-"),
)
print(f"Result: {result.message}")
print(f"Active key: {rotator.active_key.value}")

Exercise 4: Rotation dashboard with alerts

Create a dashboard that shows the rotation status of all your secrets with color-coded alerts:

See solution
def rotation_dashboard(schedules: list[ScheduledRotation]):
    print("\n╔══════════════════════════════════════════════════╗")
    print("║          ROTATION STATUS DASHBOARD              ║")
    print("╠══════════════════════════════════════════════════╣")

    for s in schedules:
        days_since = s.days_since_rotation if hasattr(s, 'days_since_rotation') else (datetime.utcnow() - s.last_rotated).days
        days_until = max(0, s.interval_days - days_since)

        if days_since > s.interval_days * 2:
            status = "🔴 CRITICAL"
        elif days_since > s.interval_days:
            status = "🟡 OVERDUE"
        elif days_until <= 7:
            status = "🔵 SOON"
        else:
            status = "🟢 OK"

        print(f"║ {s.secret_name:<30} {status:<15}   ║")
        print(f"║   Last: {days_since}d ago | Next: {days_until}d        ║")

    print("╚══════════════════════════════════════════════════╝")

Summary

  • Rotation is more important than storage — a well-stored key that's never rotated carries indefinite accumulated risk
  • The three strategies for rotation are: scheduled, on-demand (trigger-based), and continuous automatic — the ideal combination depends on your scale
  • The dual-key pattern eliminates downtime during rotation: both keys are valid during the overlap period, and the previous key is revoked afterward
  • The Rotation Scheduler runs automatic rotations and can integrate with any secrets manager
  • Post-rotation verification is mandatory: verify that the new key works before revoking the previous one
  • Rotation alerts prevent secrets from going unrotated: notify when a rotation is pending or overdue
  • The recommended intervals vary: 30 days for LLM API keys (high value), 90 days for database passwords, 180+ for signing keys
  • Automatic rollback is your safety net: if verification fails, restore the previous key without manual intervention

Next capsule: In capsule 05 you'll implement secrets management with cloud KMS — AWS Secrets Manager, GCP Secret Manager, and Azure Key Vault. You'll see working Python code for each provider, a unified interface pattern to abstract the provider, and the migration guide from .env.


Resources

  1. NIST SP 800-57 Key Management — NIST standard for cryptographic key lifecycle management
  2. AWS Secrets Manager Rotation — Documentation on automatic rotation in AWS
  3. OpenAI API Key Best Practices — Security best practices recommended by OpenAI
  4. HashiCorp Vault Dynamic Secrets — Tutorial on dynamic credentials that auto-revoke
  5. SOC 2 Key Rotation Requirements — Rotation requirements for SOC 2 compliance
  6. Zero-Downtime Secret Rotation (AWS Blog) — AWS's official pattern for zero-downtime rotation

Created: March 2026 Version: 1.0