~/2025/04/24/powershell-azure-rotate-storage-account-access-keys-on-a-schedule.md
PowerShell: Azure – Rotate Storage Account Access Keys on a Schedule
--- author: Tom Lasswell date: read: 5 min in: [ps, scripts] tags: [powershell, azure] ---
$ grep -n '^#' post.md
Every Azure Storage account ships with two 512-bit access keys so you can rotate one while the other keeps serving traffic. In practice almost nobody rotates them until an audit finds a key that has been live since the account was created. This script automates the rotation: it reads the account's KeyCreationTime to find which of the two keys is older, regenerates that one, and pushes the new connection string into Key Vault as a new secret version so consumers pick it up without a manual copy-paste.
Before you schedule it, one caveat Microsoft now puts at the top of its own rotation tutorial: for Blob, Queue and Table data the recommended fix is not better key rotation but no keys at all. Authorize clients with Microsoft Entra ID and managed identities, then set AllowSharedKeyAccess to $false on the account. Use this script for the workloads that still genuinely need a key or connection string, and treat every account it touches as a migration candidate.
Requirements
- PowerShell 7.x (Windows PowerShell 5.1 also works) with the
Az.Accounts,Az.StorageandAz.KeyVaultmodules. - On the storage account: Reader plus Storage Account Key Operator Service Role, or Contributor. The rotation needs
Microsoft.Storage/storageAccounts/listkeys/actionandMicrosoft.Storage/storageAccounts/regeneratekey/action; Owner, Contributor and Storage Account Key Operator Service Role include both. - On the vault: Key Vault Secrets Officer (RBAC permission model) or an access policy with secret Set rights.
- For unattended runs: an Azure Automation account with a system-assigned managed identity that holds the roles above.
- The account must still allow Shared Key authorization. If
AllowSharedKeyAccessis$false, the keys are not accepted anyway and the script stops without rotating.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
ResourceGroupName | String | Yes | Resource group that contains the storage account. |
StorageAccountName | String | Yes | Name of the storage account whose key is being rotated. |
KeyVaultName | String | Yes | Key Vault that receives the new connection string. |
SecretName | String | No | Secret name in the vault. Defaults to <StorageAccountName>-connection-string. |
SecretValidityDays | Int | No | Sets the secret version's expiry this many days out. Default 60. Use 0 for no expiry. |
UseManagedIdentity | Switch | No | Signs in with Connect-AzAccount -Identity first (for Automation runbooks). |
-WhatIf and -Confirm also work, because the script declares SupportsShouldProcess.
Usage
Preview which key would be rotated, without regenerating anything or writing to the vault:
.\Rotate-StorageAccountKey.ps1 -ResourceGroupName "rg-prod-data" -StorageAccountName "stprodreports01" -KeyVaultName "kv-prod-secrets" -WhatIf
key1 created 2025-01-14T09:02:11Z, key2 created 2025-02-27T09:01:48Z. Older key: key1.
What if: Performing the operation "Regenerate key1 and write secret stprodreports01-connection-string" on target "stprodreports01".
Rotate for real and show the result object:
.\Rotate-StorageAccountKey.ps1 -ResourceGroupName "rg-prod-data" -StorageAccountName "stprodreports01" -KeyVaultName "kv-prod-secrets"
key1 created 2025-01-14T09:02:11Z, key2 created 2025-02-27T09:01:48Z. Older key: key1.
StorageAccount : stprodreports01
RotatedKey : key1
SecretName : stprodreports01-connection-string
SecretVersion : 3f1c9a0e5b2d4c7e8a6f0b1d2c3e4f5a
SecretExpires : 2025-06-23 14:00:07Z
In an Azure Automation runbook, import the script as a PowerShell runbook, link it to a schedule with the parameters filled in, and pass -UseManagedIdentity so it signs in as the Automation account:
.\Rotate-StorageAccountKey.ps1 -ResourceGroupName "rg-prod-data" -StorageAccountName "stprodreports01" -KeyVaultName "kv-prod-secrets" -UseManagedIdentity
Once both keys have been rotated at least once, set a key expiration policy so the portal and Azure Policy can flag accounts that fall behind. The KeyCreationTime check mirrors Microsoft's own example, because the policy cannot be set while either value is null:
$account = Get-AzStorageAccount -ResourceGroupName "rg-prod-data" -Name "stprodreports01"
if ($null -eq $account.KeyCreationTime.Key1 -or $null -eq $account.KeyCreationTime.Key2) {
Write-Warning "Rotate both keys once before setting a key expiration policy."
} else {
Set-AzStorageAccount -ResourceGroupName "rg-prod-data" -Name "stprodreports01" -KeyExpirationPeriodInDay 60 | Out-Null
(Get-AzStorageAccount -ResourceGroupName "rg-prod-data" -Name "stprodreports01").KeyPolicy
}
With one run a month, each key is regenerated every other month, so a 60-day KeyExpirationPeriodInDay matches the schedule. Assign the built-in policy Storage account keys should not be expired at subscription scope to report accounts that miss it.
Script
<#
.SYNOPSIS
Regenerates the older of a storage account's two access keys and writes the new connection string to Key Vault.
.DESCRIPTION
Reads KeyCreationTime from the storage account to find which of key1 and key2 was created or
rotated longer ago (key1 if the timestamps are missing), regenerates that key with
New-AzStorageAccountKey, reads the new value back with Get-AzStorageAccountKey, builds a
connection string using the endpoint suffix of the current Azure environment, and stores it as a
new version of the named Key Vault secret with Set-AzKeyVaultSecret. The secret version gets an
expiry date, a content type and a CredentialId tag naming the key it holds. Stops without
rotating if the account has Shared Key authorization disabled. Supports -WhatIf and -Confirm.
.PARAMETER ResourceGroupName
Resource group that contains the storage account.
.PARAMETER StorageAccountName
Name of the storage account whose key is being rotated.
.PARAMETER KeyVaultName
Key Vault that receives the new connection string.
.PARAMETER SecretName
Secret name in the vault. Defaults to "<StorageAccountName>-connection-string".
.PARAMETER SecretValidityDays
Sets the secret version's expiry this many days out. Default 60. Use 0 for no expiry.
.PARAMETER UseManagedIdentity
Signs in with Connect-AzAccount -Identity first (for Automation runbooks).
.EXAMPLE
.\Rotate-StorageAccountKey.ps1 -ResourceGroupName "rg-prod-data" -StorageAccountName "stprodreports01" -KeyVaultName "kv-prod-secrets" -WhatIf
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2025-04-24)
Requires: Az.Accounts, Az.Storage, Az.KeyVault
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)]
[string]$ResourceGroupName,
[Parameter(Mandatory = $true)]
[string]$StorageAccountName,
[Parameter(Mandatory = $true)]
[string]$KeyVaultName,
[Parameter(Mandatory = $false)]
[string]$SecretName = "$StorageAccountName-connection-string",
[Parameter(Mandatory = $false)]
[ValidateRange(0, 3650)]
[int]$SecretValidityDays = 60,
[Parameter(Mandatory = $false)]
[switch]$UseManagedIdentity
)
$ErrorActionPreference = 'Stop'
if ($UseManagedIdentity) {
# Do not inherit a saved context inside an Automation sandbox.
Disable-AzContextAutosave -Scope Process | Out-Null
Connect-AzAccount -Identity | Out-Null
}
$storageAccount = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName
# AllowSharedKeyAccess is null until someone sets it; null and true both mean keys are accepted.
if ($storageAccount.AllowSharedKeyAccess -eq $false) {
Write-Warning "Shared Key authorization is disabled on $StorageAccountName. Nothing to rotate."
return
}
# Pick the older key. KeyCreationTime can be null on accounts whose keys were never rotated.
$key1Created = $storageAccount.KeyCreationTime.Key1
$key2Created = $storageAccount.KeyCreationTime.Key2
if ($null -eq $key1Created -or $null -eq $key2Created) {
$keyToRotate = "key1"
} elseif ($key1Created -le $key2Created) {
$keyToRotate = "key1"
} else {
$keyToRotate = "key2"
}
$key1Text = if ($key1Created) { $key1Created.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") } else { "unknown" }
$key2Text = if ($key2Created) { $key2Created.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") } else { "unknown" }
Write-Host "key1 created $key1Text, key2 created $key2Text. Older key: $keyToRotate."
if ($PSCmdlet.ShouldProcess($StorageAccountName, "Regenerate $keyToRotate and write secret $SecretName")) {
New-AzStorageAccountKey -ResourceGroupName $ResourceGroupName -Name $StorageAccountName -KeyName $keyToRotate | Out-Null
$newKey = Get-AzStorageAccountKey -ResourceGroupName $ResourceGroupName -Name $StorageAccountName |
Where-Object { $_.KeyName -eq $keyToRotate }
# Use the suffix of the signed-in environment so sovereign clouds get the right endpoint.
$endpointSuffix = (Get-AzContext).Environment.StorageEndpointSuffix
$connectionString = "DefaultEndpointsProtocol=https;AccountName=$StorageAccountName;AccountKey=$($newKey.Value);EndpointSuffix=$endpointSuffix"
$secretValue = ConvertTo-SecureString -String $connectionString -AsPlainText -Force
$secretParams = @{
VaultName = $KeyVaultName
Name = $SecretName
SecretValue = $secretValue
ContentType = "text/plain; storage-connection-string"
Tag = @{ CredentialId = $keyToRotate; StorageAccount = $StorageAccountName }
}
if ($SecretValidityDays -gt 0) {
$secretParams["Expires"] = (Get-Date).ToUniversalTime().AddDays($SecretValidityDays)
}
$secret = Set-AzKeyVaultSecret @secretParams
[PSCustomObject]@{
StorageAccount = $StorageAccountName
RotatedKey = $keyToRotate
SecretName = $SecretName
SecretVersion = $secret.Version
SecretExpires = $secret.Expires
}
}
Notes
- The script never regenerates both keys in one run, so a client holding the untouched key keeps working. That only helps if every client uses the same key: Microsoft's rotation guidance warns that if some applications use key1 and others key2, you cannot rotate without one of them losing access. Point every consumer at the Key Vault secret and nothing else.
- Regenerating a key also revokes every account SAS and service SAS signed with it. User delegation SAS tokens are signed with Entra credentials and survive rotation, which is one more reason to move SAS issuance to user delegation.
- On the very first run, clients that already hold key1 lose access, because key1 is picked when
KeyCreationTimeis empty. Follow Microsoft's manual order for the first cycle: move clients to key2 (or to the Key Vault secret seeded with key2), then run the script. - Consumers that read the secret once at startup keep the previous value until they restart. The previous secret version holds the other key, which this run did not touch; it becomes the older key and is regenerated on the next run. With a monthly schedule, every consumer therefore has a month to pick up the new version. Services that resolve the secret through Key Vault references or re-read it on a timer need no restart.
KeyCreationTimereplaces the tag-based state an earlier draft of this script kept on the storage account. The timestamp is maintained by Azure, cannot drift, and is the same value the key expiration policy uses.- The connection string is built with
EndpointSuffixfrom(Get-AzContext).Environment.StorageEndpointSuffix(for examplecore.windows.netin public Azure,core.chinacloudapi.cnin Azure China), instead of hard-coding the public cloud suffix. - Key Vault managed storage account keys (
Add-AzKeyVaultManagedStorageAccount) look like the built-in answer to this problem, but Microsoft lists the feature as legacy, supported as-is with no further updates, and points to Entra ID authorization instead. For a fully event-driven alternative to a schedule, Microsoft's dual-credential rotation tutorial uses the Key VaultSecretNearExpiryEvent Grid event (published 30 days before a secret expires) to trigger a Function that regenerates the alternate key. - To see who still uses the keys before disabling them, filter the storage account's Transactions metric by the Authentication dimension (Account Key and SAS), or query
StorageBlobLogsforAuthenticationType in ("AccountKey", "SAS")once a diagnostic setting sends resource logs to Log Analytics. Assign Storage accounts should prevent shared key access in Audit mode first, then switch it to Deny once clients are migrated.
Source
- Manage storage account access keys (rotation, key expiration policy)
- Prevent Shared Key authorization for an Azure Storage account
- Authorize operations for data access in Azure Storage
- Rotation tutorial for resources with two sets of credentials (Key Vault)
- Azure Key Vault managed storage account (legacy)
- New-AzStorageAccountKey, Set-AzStorageAccount, Set-AzKeyVaultSecret
- Using a system-assigned managed identity for an Azure Automation account