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.
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:
- Checks whether enough time has passed since the last rotation
- Rotates the key or secret on the actual Azure resource
- Stores the new value in your destination Key Vault
- Optionally tests that the new key actually works
- Rolls back automatically if anything goes wrong
- 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.
| 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 |
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.
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
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
}
}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.
- In the Azure Portal, go to App Registrations and create a new registration
- Give it a recognisable name, something like
azure-keycycle-oidc - Make a note of the Application (client) ID and the Directory (tenant) ID — you will need both shortly
- Open your new app registration and navigate to Certificates & secrets → Federated credentials → Add credential
- Choose GitHub Actions deploying Azure resources
- 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 showrepo:my-org/my-repo:ref:refs/heads/main, the federated credential must be configured with that same value.
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)
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:
- Go to your OIDC app registration → API permissions
- Add Microsoft Graph → Application permissions → Application.ReadWrite.All
- Grant admin consent
For loading config files from storage, the identity needs Storage Blob Data Reader on the storage account that holds your config.
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.
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 |
- Push your config files and code to
main - The Sync Config workflow will automatically upload all
config/rotation-config*.jsonfiles to your storage account - Go to Actions → Secret Rotation (Scheduled) and click Run workflow
- Keep Dry run set to
truefor your first attempt - Check the logs — you should see messages like
[DryRun] WOULD regenerate...for each entry - 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.
- Make sure the entry ID for
aad-app-secretis the Application (client) ID, not the Object ID — they look similar but are different values - The OIDC identity needs the
Application.ReadWrite.Allpermission (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
- The federated credential subject has to match exactly. Look in the workflow logs for the
subject claimvalue and compare it to what you configured - A common cause: the credential is set to
environment:productionbut the workflow does not specify an environment
- Double-check that
CONFIG_STORAGE_ACCOUNTandCONFIG_CONTAINER_NAMEare 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 Readeron the storage account
- The workflow bridges Azure CLI authentication into PowerShell. Make sure
enable-AzPSSession: trueis set on theazure/login@v2step in your workflow - If you see
Scopes=[]in the diagnostic logs, the Az PowerShell token is not being scoped correctly
- 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 Roleon the storage account
- 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
- Pass the Application (client) ID to
-ClientId, not the Object ID - The scripts use
--assignee, which resolves the principal type automatically
- This warning comes from the Az PowerShell module itself and is harmless. The key still gets regenerated successfully. You can safely ignore it.
- 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
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
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.
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 -ExpiryCheckOnlyThe test suite uses Pester. To run it:
Install-Module Pester -Force
Invoke-Pester ./tests/Unit -Output DetailedMIT
Charlie Al-Batty — LinkedIn
