All Posts Automation

Azure Key Vault and Certificate Automation: A Production Guide

Your SSL certificate expired on a Friday afternoon. The website went down.

Azure Key Vault automates certificate lifecycle management and secret rotation by centralizing credentials in a managed cloud service that costs under $5 per month for small businesses in Volusia County, eliminating the category of outages caused by expired SSL certificates and the security risk of passwords that never change. Businesses across Volusia County — in Daytona Beach, Port Orange, and Ormond Beach — running websites with manually renewed certificates are one missed calendar reminder away from a complete site outage.

Your SSL certificate expired on a Friday afternoon. The website went down. Customers saw browser warnings about insecure connections. Someone noticed forty-five minutes later. Your IT person renewed the certificate manually, installed it on the server, restarted the web service, and the site came back. Then they set a calendar reminder for next year. That calendar reminder is your entire certificate management strategy.

Azure Key Vault automation eliminates this category of failure by centralizing secrets and certificates in a managed cloud service that handles rotation, expiration monitoring, and access control automatically. Instead of calendar reminders and manual renewals, your certificates renew themselves, your secrets rotate on schedule, and your applications retrieve credentials at runtime from a system designed specifically for this purpose.

This scenario plays out across Volusia County — businesses in Daytona Beach, Port Orange, Ormond Beach — running websites and applications with SSL certificates renewed manually once a year. Or storing database passwords in plain text configuration files that haven’t been rotated since the application was deployed. Or keeping API keys in environment variables on servers where anyone with SSH access can read them.

None of this is necessary in 2026. Azure Key Vault is cheap, straightforward to set up, and solves a category of security problems that manual processes can never reliably address. In this guide, I’m walking you through setting up Key Vault from scratch with Terraform, automating certificate lifecycle management with Python, and building a rotation pipeline that handles secrets and certificates without human intervention.

Why Manual Certificate Management Fails

Certificate expiration is a cliff, not a slope. Your certificate works perfectly on day 364 and completely stops working on day 365. There’s no graceful degradation. There’s no warning that users see. One minute your site is fine, the next minute every browser in the world is telling your customers that your site is dangerous.

Manual processes fail at exactly this kind of boundary. The calendar reminder fires. You’re in a meeting. You snooze it. You forget. The certificate expires.

Automated rotation eliminates the gap entirely. There’s no human step between “certificate is approaching expiration” and “certificate is renewed.” The system handles it. Every time. On schedule.

The same logic applies to secret rotation. Database passwords that never change are a security liability. If an employee who knew the database password leaves the company, that password should change. If it doesn’t — because changing it requires touching four different config files on three different servers and nobody wants to risk the downtime — you have a security hole that grows over time.

Step 1: Provision Key Vault with Terraform

Infrastructure as code means your Key Vault configuration is version-controlled, reviewable, and repeatable. Here’s a complete Terraform configuration that provisions a Key Vault with sensible defaults for a small business environment:

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.85"
    }
  }
}

provider "azurerm" {
  features {
    key_vault {
      purge_soft_deleted_secrets_on_destroy = false
      recover_soft_deleted_secrets          = true
    }
  }
}

data "azurerm_client_config" "current" {}

resource "azurerm_resource_group" "keyvault" {
  name     = "rg-keyvault-prod"
  location = "eastus"
  tags     = { environment = "production", managed_by = "terraform" }
}

resource "azurerm_key_vault" "main" {
  name                = "kv-company-prod-001"
  location            = azurerm_resource_group.keyvault.location
  resource_group_name = azurerm_resource_group.keyvault.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "standard"

  soft_delete_retention_days = 30
  purge_protection_enabled   = true
  enable_rbac_authorization  = true

  network_acls {
    default_action = "Deny"
    bypass         = "AzureServices"
    ip_rules       = ["203.0.113.0/24"]  # Replace with your IP range
  }

  tags = { environment = "production", managed_by = "terraform" }
}

resource "azurerm_role_assignment" "terraform_admin" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Administrator"
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "azurerm_role_assignment" "app_secrets_reader" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = var.app_managed_identity_principal_id
}

resource "azurerm_key_vault_certificate" "internal_cert" {
  name         = "internal-api-cert"
  key_vault_id = azurerm_key_vault.main.id

  certificate_policy {
    issuer_parameters { name = "Self" }
    key_properties {
      exportable = true
      key_size   = 2048
      key_type   = "RSA"
      reuse_key  = false
    }
    lifetime_action {
      action { action_type = "AutoRenew" }
      trigger { days_before_expiry = 30 }
    }
    secret_properties { content_type = "application/x-pkcs12" }
    x509_certificate_properties {
      subject            = "CN=api.internal.company.com"
      validity_in_months = 12
      key_usage          = ["digitalSignature", "keyEncipherment"]
      extended_key_usage = ["1.3.6.1.5.5.7.3.1"]
    }
  }

  depends_on = [azurerm_role_assignment.terraform_admin]
}

variable "app_managed_identity_principal_id" {
  description = "Principal ID of the application's managed identity"
  type        = string
}

output "key_vault_uri" {
  value = azurerm_key_vault.main.vault_uri
}

Let me walk through the key decisions. The purge_protection_enabled flag is critical. With purge protection on, deleted secrets go into a soft-deleted state for 30 days before permanent removal. This prevents accidental or malicious permanent deletion of your production secrets.

The enable_rbac_authorization flag uses Azure’s role-based access control instead of Key Vault’s older access policies. RBAC is more flexible and integrates with Entra ID groups. For a deeper look at this topic, see our guide on CI/CD for Non-Software Companies: Automating Your Infrastructure Deployments.

The network_acls block restricts access to your office IP range. Even if someone compromises a credential with Key Vault access, they can’t use it from outside your network.

Step 2: Build the Certificate Rotation Script

For certificates from CAs that don’t integrate natively with Key Vault, you need a rotation script. This Python script handles the full lifecycle — checking expiration dates, requesting new certificates, importing them into Key Vault, and alerting your team:

#!/usr/bin/env python3
"""
cert_rotation.py
Monitors certificate expiration in Azure Key Vault and handles
rotation for certificates from external certificate authorities.

Requirements:
    pip install azure-identity azure-keyvault-certificates
"""




from datetime import datetime, timedelta, timezone
from pathlib import Path

from azure.identity import DefaultAzureCredential
from azure.keyvault.certificates import CertificateClient

VAULT_URL = "https://kv-company-prod-001.vault.azure.net/"
ROTATION_THRESHOLD_DAYS = 30
LOG_DIR = Path("./logs")
LOG_DIR.mkdir(exist_ok=True)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler(LOG_DIR / "cert-rotation.log"),
        logging.StreamHandler(),
    ],
)
logger = logging.getLogger(__name__)


def get_cert_client():
    """Create authenticated Key Vault certificate client."""
    credential = DefaultAzureCredential()
    return CertificateClient(vault_url=VAULT_URL, credential=credential)


def check_certificate_expiration(cert_client):
    """Check all certificates and return those approaching expiration."""
    expiring = []
    now = datetime.now(timezone.utc)
    threshold = now + timedelta(days=ROTATION_THRESHOLD_DAYS)

    logger.info("Scanning certificates in vault...")

    for cert_properties in cert_client.list_properties_of_certificates():
        cert = cert_client.get_certificate(cert_properties.name)

        if cert.policy and cert.policy.expires_on:
            expires = cert.policy.expires_on
            if not expires.tzinfo:
                expires = expires.replace(tzinfo=timezone.utc)

            days_remaining = (expires - now).days

            status = {
                "name": cert.name,
                "expires": expires.isoformat(),
                "days_remaining": days_remaining,
            }

            if days_remaining <= ROTATION_THRESHOLD_DAYS:
                status["action"] = "ROTATION_NEEDED"
                expiring.append(status)
                logger.warning(
                    f"Certificate '{cert.name}' expires in "
                    f"{days_remaining} days -- rotation needed"
                )
            else:
                logger.info(f"Certificate '{cert.name}': {days_remaining} days remaining -- OK")

    return expiring


def import_renewed_certificate(cert_client, cert_name, pfx_path, password=""):
    """Import a renewed certificate into Key Vault."""
    pfx_file = Path(pfx_path)
    if not pfx_file.exists():
        logger.error(f"Certificate file not found: {pfx_path}")
        return False

    with open(pfx_file, "rb") as f:
        pfx_bytes = f.read()

    try:
        cert_client.import_certificate(
            certificate_name=cert_name,
            certificate_bytes=pfx_bytes,
            password=password if password else None,
        )
        logger.info(f"Certificate '{cert_name}' imported successfully")
        return True
    except Exception as e:
        logger.error(f"Failed to import certificate '{cert_name}': {e}")
        return False


def generate_rotation_report(expiring_certs):
    """Generate a JSON report of certificates needing rotation."""
    report = {
        "generated": datetime.now(timezone.utc).isoformat(),
        "vault": VAULT_URL,
        "threshold_days": ROTATION_THRESHOLD_DAYS,
        "certificates_requiring_action": len(expiring_certs),
        "certificates": expiring_certs,
    }

    report_path = LOG_DIR / f"rotation-report-{datetime.now():%Y%m%d}.json"
    with open(report_path, "w") as f:
        json.dump(report, f, indent=2)

    logger.info(f"Report saved: {report_path}")
    return report


def main():
    logger.info("Certificate rotation check started")

    try:
        cert_client = get_cert_client()
    except Exception as e:
        logger.error(f"Failed to authenticate to Key Vault: {e}")
        sys.exit(1)

    expiring = check_certificate_expiration(cert_client)
    report = generate_rotation_report(expiring)

    if expiring:
        logger.warning(
            f"ACTION REQUIRED: {len(expiring)} certificate(s) "
            f"need rotation within {ROTATION_THRESHOLD_DAYS} days"
        )
        sys.exit(2)
    else:
        logger.info("All certificates are within acceptable expiration range")
        sys.exit(0)


if __name__ == "__main__":
    main()

The ROTATION_THRESHOLD_DAYS is set to 30, meaning the script alerts you 30 days before expiration — giving you time to handle certificates that require manual steps.

The DefaultAzureCredential class handles authentication automatically. On Azure with a managed identity, it uses that identity. Running locally, it falls back to the Azure CLI credential. You don’t need to manage a service account password for the rotation script itself. For technical background, our knowledge base article on automated retraining pipelines provides a solid foundation.

The exit codes matter for CI/CD. Exit code 0 means all certificates are fine. Exit code 2 means certificates need attention and will flag a pipeline run as failed.

Step 3: Automate Secret Rotation

Secrets — database passwords, API keys, connection strings — are the bigger security risk because they expire invisibly. A compromised password works silently until someone notices the breach, which might be months or never. For related strategies, check out How to Set Up Automated Backups for Your Small Business (Free Script).

#!/usr/bin/env python3
"""
secret_rotation.py
Automated secret rotation for Azure Key Vault.

Requirements:
    pip install azure-identity azure-keyvault-secrets
"""





from datetime import datetime, timedelta, timezone

from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

VAULT_URL = "https://kv-company-prod-001.vault.azure.net/"
ROTATION_DAYS = 90

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


def generate_password(length=32):
    """Generate a cryptographically secure random password."""
    alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
    while True:
        password = "".join(secrets.choice(alphabet) for _ in range(length))
        if (any(c.isupper() for c in password) and
                any(c.islower() for c in password) and
                any(c.isdigit() for c in password) and
                any(c in "!@#$%^&*" for c in password)):
            return password


def rotate_secret(client, secret_name, new_value=None):
    """Rotate a secret by setting a new value with expiration."""
    if new_value is None:
        new_value = generate_password()

    expiration = datetime.now(timezone.utc) + timedelta(days=ROTATION_DAYS)

    try:
        client.set_secret(
            secret_name,
            new_value,
            content_type="password",
            expires_on=expiration,
            tags={
                "rotated_on": datetime.now(timezone.utc).isoformat(),
                "rotation_method": "automated",
                "next_rotation": expiration.isoformat(),
            },
        )
        logger.info(f"Secret '{secret_name}' rotated. Expires: {expiration.date()}")
        return new_value
    except Exception as e:
        logger.error(f"Failed to rotate secret '{secret_name}': {e}")
        return None


def audit_all_secrets(client):
    """Audit all secrets and report age status."""
    results = []

    for prop in client.list_properties_of_secrets():
        if not prop.enabled:
            continue

        secret = client.get_secret(prop.name)
        updated = secret.properties.updated_on or secret.properties.created_on
        if not updated.tzinfo:
            updated = updated.replace(tzinfo=timezone.utc)

        age_days = (datetime.now(timezone.utc) - updated).days
        status = "OVERDUE" if age_days >= ROTATION_DAYS else (
            "DUE_SOON" if age_days >= ROTATION_DAYS - 14 else "OK"
        )

        results.append({"name": prop.name, "age_days": age_days, "status": status})

        log_fn = logger.warning if status != "OK" else logger.info
        log_fn(f"  {prop.name}: {age_days} days old [{status}]")

    return results


def main():
    logger.info("Secret rotation check started")

    credential = DefaultAzureCredential()
    client = SecretClient(vault_url=VAULT_URL, credential=credential)

    results = audit_all_secrets(client)

    rotated = 0
    for result in results:
        if result["status"] == "OVERDUE":
            logger.info(f"Auto-rotating: {result['name']}")
            new_value = rotate_secret(client, result["name"])
            if new_value:
                rotated += 1
                # Update downstream services here (database, API provider, etc.)

    logger.info(f"Rotation complete. {rotated} secret(s) rotated.")
    sys.exit(0 if rotated == 0 else 2)


if __name__ == "__main__":
    main()

The generate_password function uses Python’s secrets module, which is cryptographically secure. The tags on each rotated secret create an audit trail — when your compliance auditor asks “when was the database password last rotated?”, you query Key Vault’s tags instead of searching through log files.

Step 4: Schedule with GitHub Actions

name: Certificate & Secret Rotation Check

on:
  schedule:
    - cron: "0 8 * * *"
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  rotation-check:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install azure-identity azure-keyvault-certificates azure-keyvault-secrets

      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Check certificate expiration
        run: python cert_rotation.py

      - name: Audit secret rotation
        run: python secret_rotation.py

      - name: Upload rotation reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: rotation-reports
          path: logs/
          retention-days: 90

This workflow uses OIDC federated credentials to authenticate with Azure — no stored secrets for the authentication itself. If either script exits with a non-zero code, the workflow fails and triggers notifications.

Common Pitfalls

Not enabling soft delete and purge protection. If you accidentally delete a secret without soft delete, it’s gone permanently. Always enable both.

Storing the Key Vault access credential in a config file. This defeats the purpose of Key Vault entirely. Use managed identities for Azure workloads and OIDC federation for CI/CD pipelines.

Setting overly broad network ACLs. The default action should always be “Deny” with explicit allowlists for your IP ranges. “Allow all” means your Key Vault is accessible from anywhere on the internet.

Not testing certificate renewal before the first real expiration. Create a short-lived test certificate, let it approach expiration, and verify the rotation automation handles it correctly.

FAQ

What is Azure Key Vault?

Azure Key Vault is Microsoft’s cloud service for securely storing and managing secrets, encryption keys, and SSL/TLS certificates. It provides centralized secret management with fine-grained access control, audit logging, and hardware security module backing.

How does Azure Key Vault handle certificate rotation?

Key Vault can automatically rotate certificates from integrated CAs like DigiCert and GlobalSign. For self-signed certificates, Key Vault generates new versions automatically. For other CAs, Event Grid notifications trigger custom automation before expiration.

Can I automate certificate renewal with Azure Key Vault?

Yes. For integrated CAs, Key Vault handles renewal automatically. For other certificates, use Azure Event Grid notifications to trigger automated renewal workflows via Python scripts that call the Key Vault SDK.

How much does Azure Key Vault cost for small businesses?

Azure Key Vault Standard tier costs approximately $0.03 per 10,000 operations. For a small business managing 10 to 50 secrets and a handful of certificates, the monthly cost is typically under $5.

Should I use Azure Key Vault or store secrets in environment variables?

Azure Key Vault is significantly more secure. Environment variables are visible to any process, appear in crash dumps, and have no access auditing. Key Vault provides encrypted storage, RBAC, audit logging, and automatic rotation.

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.