Skip to content

IamCharli01/Azure-KeyCycle

Repository files navigation

Azure KeyCycle

Azure KeyCycle 🔑🔄

Keeping secrets rotated across Azure is one of those tasks that feels simple until you actually have to do it reliably, at scale, without breaking anything. Azure KeyCycle takes that burden off your plate.

It is a PowerShell engine that rotates your Azure secrets on a schedule, stores them safely in Key Vault, checks that everything still works afterwards, and rolls back if it does not. You define what needs rotating in a JSON config file, and the engine handles the rest.

It runs on GitHub Actions using OIDC federation, so there are no stored credentials to worry about. You can also run it locally, through a service principal, or via Ansible if that better fits your environment.

What happens during a rotation

When the engine runs (daily at 2am UTC by default, or whenever you trigger it manually), it walks through your config and for each secret:

  1. Checks whether enough time has passed since the last rotation
  2. Rotates the key or secret on the actual Azure resource
  3. Stores the new value in your destination Key Vault
  4. Optionally tests that the new key actually works
  5. Rolls back automatically if anything goes wrong
  6. Logs every step as structured JSON for your audit trail

For resources that use key pairs (storage accounts, OpenAI, Service Bus, and so on), the engine uses an alternating key slot approach. It regenerates the key that is not currently in use, so your downstream systems are never left holding an invalid key.

What you can rotate

Type What gets rotated Health check Stored as
aad-app-secret App registration client secret OAuth token exchange Secret value
storage-account-key Storage account access key Blob listing Connection string or key
azure-openai-key Cognitive Services API key Models API call API key
service-bus-key Service Bus namespace key Connection string or key
event-hub-key Event Hub namespace key Connection string or key
cosmos-db-key Cosmos DB account key Connection string or key
redis-cache-key Redis Cache access key Connection string or key

How the rotation strategies work

App registration secrets

App registrations are handled differently from infrastructure keys. Instead of swapping between two slots, the engine creates a brand new secret alongside the one that is about to expire. Your downstream systems keep using the current secret while they transition to the new one. Once the old secret naturally expires, it gets cleaned up.

If the engine sees that a valid secret and an expiring secret both already exist, it knows a rotation is already in progress and leaves things alone.

Infrastructure keys (Storage, OpenAI, Service Bus, Event Hub, Cosmos DB, Redis)

These resources have two key slots. The engine reads a RotatedKeySlot tag from your Key Vault secret to figure out which slot was used last time, then regenerates the opposite one. After storing the new key with updated tags, it will not touch that secret again until the configured interval has passed.

This means you can safely trigger the pipeline as many times as you like. Only the first run within each interval window actually performs a rotation.

Day 0:    Rotates key1 → tags: RotatedAt=2026-03-23, RotatedKeySlot=key1
Day 1:    Reads tags → "Last rotated 1 day ago, threshold is 30 days — skipping"
Day 15:   Still too recent — skipping
Day 30:   "Last rotated 30 days ago — time to rotate" → Rotates key2

Rotating across multiple subscriptions

If you manage resources across several Azure subscriptions, you can create a separate config file for each one. Every config file declares its own targetSubscriptionId, and the engine handles switching context between them during a single run.

config/
├── rotation-config-prod.json       # targets the production subscription
├── rotation-config-staging.json    # targets the staging subscription
└── rotation-config-dev.json        # targets the dev subscription

The workflow discovers all matching config files, loads each one independently, rotates the entries within each subscription, and produces separate audit trails for each.

If you only have one subscription, nothing changes for you. Just add targetSubscriptionId to your existing config and carry on as before.

Example config:

{
  "targetSubscriptionId": "00000000-000000000000",
  "_comment": "Production subscription secrets",

  "prod-storage": {
    "secretType": "storage-account-key",
    "vault": "prod-keyvault",
    "kv_secret_name": "Storage--ConnectionString",
    "storageAccountName": "prodstorage01",
    "resourceGroup": "rg-prod",
    "rotationIntervalDays": 30
  }
}

Getting started with GitHub Actions and OIDC

This is the recommended approach. Your GitHub Actions workflow authenticates to Azure through OpenID Connect federation, so you never have to store Azure credentials in GitHub.

Step 1 — Register an app for OIDC

  1. In the Azure Portal, go to App Registrations and create a new registration
  2. Give it a recognisable name, something like azure-keycycle-oidc
  3. Make a note of the Application (client) ID and the Directory (tenant) ID — you will need both shortly

Step 2 — Set up the federated credential

  1. Open your new app registration and navigate to Certificates & secrets → Federated credentials → Add credential
  2. Choose GitHub Actions deploying Azure resources
  3. Fill in your details:
    • Organization: your GitHub org or username
    • Repository: the name of this repo
    • Entity type: Branch
    • Branch: main
    • Name: whatever makes sense to you

⚠️ The subject claim needs to match exactly. If your workflow logs show repo:my-org/my-repo:ref:refs/heads/main, the federated credential must be configured with that same value.

Step 3 — Create the Azure resources

You will need three things:

  • A Storage Account with a blob container to hold your rotation config files
  • A Key Vault where the rotated secrets will be stored
  • The resources whose secrets you want to rotate (app registrations, storage accounts, OpenAI instances, and so on)

Step 4 — Grant permissions

The scripts/ folder includes setup scripts that assign the right RBAC roles for each resource type. Run them after logging in with az login.

Give the identity access to your Key Vault:

az login
./scripts/Setup-KeyVaultPermissions.ps1 -KeyVaultName "my-keyvault" -ClientId "your-oidc-app-client-id"

Then grant access for each resource you want to rotate:

# Storage account
./scripts/Setup-StorageKeyPermissions.ps1 -StorageAccountName "mysa" -ResourceGroup "rg-prod" -ClientId "your-oidc-app-client-id"

# Azure OpenAI
./scripts/Setup-OpenAIKeyPermissions.ps1 -OpenAIAccountName "myopenai" -ResourceGroup "rg-prod" -ClientId "your-oidc-app-client-id"

# Service Bus
./scripts/Setup-ServiceBusKeyPermissions.ps1 -NamespaceName "my-sb" -ResourceGroup "rg-prod" -ClientId "your-oidc-app-client-id"

# Event Hub
./scripts/Setup-EventHubKeyPermissions.ps1 -NamespaceName "my-eh" -ResourceGroup "rg-prod" -ClientId "your-oidc-app-client-id"

# Cosmos DB
./scripts/Setup-CosmosDBKeyPermissions.ps1 -CosmosDBAccountName "mycosmosdb" -ResourceGroup "rg-prod" -ClientId "your-oidc-app-client-id"

# Redis Cache
./scripts/Setup-RedisCacheKeyPermissions.ps1 -CacheName "myredis" -ResourceGroup "rg-prod" -ClientId "your-oidc-app-client-id"

For app registration secret rotation, the OIDC identity also needs Graph API permissions:

  1. Go to your OIDC app registration → API permissions
  2. Add Microsoft Graph → Application permissions → Application.ReadWrite.All
  3. Grant admin consent

For loading config files from storage, the identity needs Storage Blob Data Reader on the storage account that holds your config.

Step 5 — Write your config file

Create a file at config/rotation-config.json (or use multiple files like rotation-config-prod.json and rotation-config-staging.json):

{
  "targetSubscriptionId": "d0290f21-441c-4451-b7e6-your-sub-id",
  "_comment": "Set enabled to false to skip an entry. rotationIntervalDays defaults to 30.",

  "e7c46502-0000-0000-0000-000000000000": {
    "enabled": true,
    "secretType": "aad-app-secret",
    "vault": "my-keyvault",
    "kv_secret_name": "myapp-client-secret"
  },

  "my-storage-account": {
    "enabled": true,
    "secretType": "storage-account-key",
    "vault": "my-keyvault",
    "kv_secret_name": "storage-connection-string",
    "storageAccountName": "mystorageaccount",
    "resourceGroup": "rg-prod",
    "keyToRegenerate": "key1",
    "storeFormat": "connection-string",
    "rotationIntervalDays": 30,
    "healthCheck": {
      "type": "blob-list"
    }
  },

  "my-openai": {
    "enabled": true,
    "secretType": "azure-openai-key",
    "vault": "my-keyvault",
    "kv_secret_name": "openai-api-key",
    "accountName": "myopenai",
    "resourceGroup": "rg-prod",
    "keyToRegenerate": "Key1",
    "healthCheck": {
      "type": "openai-deployments",
      "endpoint": "https://myopenai.openai.azure.com",
      "apiVersion": "2024-06-01"
    }
  }
}

What each field means:

Field Required Description
targetSubscriptionId Yes The Azure subscription where your Key Vaults and resources live
enabled No Set to false to temporarily skip an entry (defaults to true)
secretType Yes One of the seven supported types listed above
vault Yes The name of the Key Vault where the rotated secret should be stored
kv_secret_name Yes The name to give the secret inside Key Vault
keyToRegenerate No Which key slot to start with on the very first rotation
storeFormat No "connection-string" (the default) or "key-only"
rotationIntervalDays No How many days to wait between rotations (defaults to 30)
healthCheck No How to verify the new key works — leave it out to skip testing

A note about entry IDs (the JSON key for each entry):

  • For aad-app-secret, this must be the Application (client) ID from the app registration
  • For all other types, it can be any unique string you find meaningful (like prod-storage-01)

⚠️ OpenAI endpoint URLs are case-sensitive. Copy the exact URL from the Azure Portal — it is usually all lowercase.

Step 6 — Add your secrets to GitHub

Go to your repository's Settings → Secrets and variables → Actions and create:

Secret What it is Example
AZURE_CLIENT_ID The client ID from your OIDC app registration 4deer79782f-c68d-4bad-...
AZURE_TENANT_ID Your Azure AD tenant ID dbhs34-4ca4-49c3-...
AZURE_SUBSCRIPTION_ID The subscription used for OIDC login and config storage d02e34855-441c-4451-...
CONFIG_STORAGE_ACCOUNT The storage account that holds your config files myconfgstorage
CONFIG_CONTAINER_NAME The blob container name config
CONFIG_BLOB_PREFIX (Optional) A prefix to filter config blobs — defaults to rotation-config rotation-config

Step 7 — Try it out

  1. Push your config files and code to main
  2. The Sync Config workflow will automatically upload all config/rotation-config*.json files to your storage account
  3. Go to Actions → Secret Rotation (Scheduled) and click Run workflow
  4. Keep Dry run set to true for your first attempt
  5. Check the logs — you should see messages like [DryRun] WOULD regenerate... for each entry
  6. Once everything looks right, run it again with Dry run set to false

After that, the schedule takes over and runs daily at 2am UTC. Scheduled runs perform real rotations (not dry runs), and infrastructure keys will only rotate once their rotationIntervalDays interval has elapsed.


When things go wrong

"App registration not found"

  • Make sure the entry ID for aad-app-secret is the Application (client) ID, not the Object ID — they look similar but are different values
  • The OIDC identity needs the Application.ReadWrite.All permission (as an Application permission, not Delegated), and admin consent must be granted
  • Verify the app registration lives in the same tenant as your OIDC identity

"AADSTS700213: No matching federated identity record found"

  • The federated credential subject has to match exactly. Look in the workflow logs for the subject claim value and compare it to what you configured
  • A common cause: the credential is set to environment:production but the workflow does not specify an environment

"Can not find blob in container"

  • Double-check that CONFIG_STORAGE_ACCOUNT and CONFIG_CONTAINER_NAME are spelled exactly right (they are case-sensitive)
  • If you are using CONFIG_BLOB_PREFIX, make sure your blob names actually start with that prefix
  • The Sync Config workflow needs to have run at least once before there are any blobs to find
  • The OIDC identity needs Storage Blob Data Reader on the storage account

"Context cannot be null" or Azure PowerShell authentication failures

  • The workflow bridges Azure CLI authentication into PowerShell. Make sure enable-AzPSSession: true is set on the azure/login@v2 step in your workflow
  • If you see Scopes=[] in the diagnostic logs, the Az PowerShell token is not being scoped correctly

Storage key health check returns 403

  • This is most often a propagation delay. The engine waits 10 seconds before running health checks, but occasionally Azure needs a bit longer to propagate the new key
  • If it keeps happening, check that the OIDC identity has the Storage Account Key Operator Service Role on the storage account

OpenAI health check returns 404

  • The endpoint URL is case-sensitive — copy it directly from the Azure Portal (it is usually all lowercase)
  • Make sure you have at least one model deployed in your OpenAI resource
  • The health check calls /openai/models, so if that path returns 404, the endpoint URL is wrong

"PrincipalTypeNotSupported" when running RBAC scripts

  • Pass the Application (client) ID to -ClientId, not the Object ID
  • The scripts use --assignee, which resolves the principal type automatically

Cosmos DB "Invalid value for KeyKind"

  • This warning comes from the Az PowerShell module itself and is harmless. The key still gets regenerated successfully. You can safely ignore it.

A health check failed, but the key was already regenerated

  • The engine handles this for you. It automatically rolls back by restoring the previous Key Vault secret version
  • The key on the Azure resource itself has already been regenerated (there is no way to undo that), but because of the alternating slot pattern, your downstream systems should still be using the other key
  • Check the run summary to confirm the rollback completed

Project layout

azure-keycycle/
├── .github/workflows/
│   ├── rotate-secrets.yml          # The main rotation workflow (daily schedule + manual trigger)
│   ├── sync-config.yml             # Pushes config changes to Azure Storage automatically
│   └── ci.yml                      # Runs Pester tests on PRs and pushes
├── config/
│   ├── rotation-config.json        # Your config file(s), synced to Azure Storage
│   ├── rotation-config-prod.json   # Optional per-subscription configs
│   ├── rotation-config-staging.json
│   └── rotation-config.example.json
├── scripts/                        # One RBAC setup script per resource type
│   ├── Setup-KeyVaultPermissions.ps1
│   ├── Setup-AADAppSecretPermissions.ps1
│   ├── Setup-StorageKeyPermissions.ps1
│   ├── Setup-OpenAIKeyPermissions.ps1
│   ├── Setup-ServiceBusKeyPermissions.ps1
│   ├── Setup-EventHubKeyPermissions.ps1
│   ├── Setup-CosmosDBKeyPermissions.ps1
│   └── Setup-RedisCacheKeyPermissions.ps1
├── src/AzureKeyCycle/
│   ├── AzureKeyCycle.psd1          # Module manifest
│   ├── AzureKeyCycle.psm1          # Module loader
│   ├── Public/                     # The functions you interact with directly
│   │   ├── Import-RotationConfig.ps1
│   │   ├── Import-AllRotationConfigs.ps1
│   │   ├── Invoke-SecretRotation.ps1
│   │   ├── Test-SecretHealth.ps1
│   │   ├── Undo-SecretRotation.ps1
│   │   ├── Assert-SecretExpiry.ps1
│   │   └── Get-RotationStatus.ps1
│   └── Private/                    # Internal handlers that do the actual work
│       ├── Invoke-AADAppSecretRotation.ps1
│       ├── Invoke-StorageKeyRotation.ps1
│       ├── Invoke-OpenAIKeyRotation.ps1
│       ├── Invoke-ServiceBusKeyRotation.ps1
│       ├── Invoke-EventHubKeyRotation.ps1
│       ├── Invoke-CosmosDBKeyRotation.ps1
│       ├── Invoke-RedisCacheKeyRotation.ps1
│       ├── Connect-ToGraph.ps1
│       ├── Get-AlternateKeySlot.ps1
│       ├── Test-RotationInterval.ps1
│       ├── Write-AuditLog.ps1
│       └── Write-RotationSummary.ps1
├── tests/Unit/
│   └── AzureKeyCycle.Tests.ps1
├── Run-Rotation.ps1                # Standalone entry point for local or VM usage
└── README.md

Audit logging

Every action the engine takes is recorded as structured JSON. Set the KEYCYCLE_LOG_PATH environment variable to write these logs to a file (the GitHub Actions workflow does this for you):

{
  "timestamp": "2026-03-23T07:43:31Z",
  "action": "Rotating",
  "clientId": "my-storage-account",
  "message": "Regenerating key2 for storage account 'mysa' (alternating slot)",
  "severity": "Info",
  "hostname": "fv-az700-143"
}

In the GitHub Actions workflow, these logs are uploaded as artifacts and kept for 90 days.

Running locally

You do not need GitHub Actions to use Azure KeyCycle. The Run-Rotation.ps1 script supports several ways to authenticate and load config:

# Single config file with interactive login and dry run
./Run-Rotation.ps1 -ConfigPath ./config/rotation-config.json -DryRun

# Multiple config files from a directory
./Run-Rotation.ps1 -ConfigDirectory ./config/ -DryRun

# Load configs from Azure Storage (discovers all matching blobs)
./Run-Rotation.ps1 -UseManagedIdentity `
  -StorageAccount "mysa" -Container "config"

# Load a specific blob from Azure Storage
./Run-Rotation.ps1 -UseManagedIdentity `
  -StorageAccount "mysa" -Container "config" -Blob "rotation-config-prod.json"

# Authenticate with a service principal
./Run-Rotation.ps1 -UseServicePrincipal `
  -TenantId "your-tenant-id" `
  -SpClientId "your-sp-client-id" `
  -SpClientSecret "your-sp-secret" `
  -ConfigDirectory ./config/

# See the current status of all your secrets without changing anything
./Run-Rotation.ps1 -ConfigDirectory ./config/ -StatusOnly

# Check which secrets are approaching expiry
./Run-Rotation.ps1 -ConfigPath ./config/rotation-config.json -ExpiryCheckOnly

Development

The test suite uses Pester. To run it:

Install-Module Pester -Force
Invoke-Pester ./tests/Unit -Output Detailed

License

MIT

Author

Charlie Al-Batty — LinkedIn

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors