~/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: 
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.Storage and Az.KeyVault modules.
  • On the storage account: Reader plus Storage Account Key Operator Service Role, or Contributor. The rotation needs Microsoft.Storage/storageAccounts/listkeys/action and Microsoft.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 AllowSharedKeyAccess is $false, the keys are not accepted anyway and the script stops without rotating.

Parameters

NameTypeRequiredDescription
ResourceGroupNameStringYesResource group that contains the storage account.
StorageAccountNameStringYesName of the storage account whose key is being rotated.
KeyVaultNameStringYesKey Vault that receives the new connection string.
SecretNameStringNoSecret name in the vault. Defaults to <StorageAccountName>-connection-string.
SecretValidityDaysIntNoSets the secret version's expiry this many days out. Default 60. Use 0 for no expiry.
UseManagedIdentitySwitchNoSigns 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:

powershell
.\Rotate-StorageAccountKey.ps1 -ResourceGroupName "rg-prod-data" -StorageAccountName "stprodreports01" -KeyVaultName "kv-prod-secrets" -WhatIf
text
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:

powershell
.\Rotate-StorageAccountKey.ps1 -ResourceGroupName "rg-prod-data" -StorageAccountName "stprodreports01" -KeyVaultName "kv-prod-secrets"
text
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:

powershell
.\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:

powershell
$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

powershell
<#
.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 KeyCreationTime is 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.
  • KeyCreationTime replaces 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 EndpointSuffix from (Get-AzContext).Environment.StorageEndpointSuffix (for example core.windows.net in public Azure, core.chinacloudapi.cn in 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 Vault SecretNearExpiry Event 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 StorageBlobLogs for AuthenticationType 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