Skip to content

Instantly share code, notes, and snippets.

@jpawlowski
Last active March 16, 2026 13:37
Show Gist options
  • Select an option

  • Save jpawlowski/b5c789980f59206b76a4d0f9809a8755 to your computer and use it in GitHub Desktop.

Select an option

Save jpawlowski/b5c789980f59206b76a4d0f9809a8755 to your computer and use it in GitHub Desktop.
Verify HMAC signature of incoming Azure Automation webhook requests.
function Test-HmacAuthorization {
<#
.SYNOPSIS
Verifies HMAC signature of incoming Azure Automation webhook requests.
.DESCRIPTION
Validates HMAC signature based on signed request headers (timestamp, nonce, content hash) and a shared secret.
Supports both HMACSHA256 and HMACSHA512 algorithms.
The shared secret is passed securely as a SecureString, converted as late as possible, and cleared from memory immediately after use.
Signature verification includes:
- Timestamp freshness validation (anti-replay window configurable via AllowedTimeDriftMinutes)
- Host header validation against expected host
- Body content hash integrity check
- Nonce inclusion for replay protection (future extension to store/check nonce is left open)
.EXAMPLE
# ✅ Example 1: Production (Azure Automation)
# Retrieve encrypted variable securely
$sharedSecret = (Get-AutomationVariable -Name "SharedSecret") | ConvertTo-SecureString -AsPlainText -Force
# Verify signature
if (-not (Test-HmacAuthorization -SharedSecret $sharedSecret -WebhookData $WebhookData)) {
[void]$sharedSecret.Dispose()
throw "Unauthorized: Signature verification failed."
}
# Dispose secret after use
[void]$sharedSecret.Dispose()
.EXAMPLE
# ✅ Example 2: Local Development / Testing
# Use test shared secret (DO NOT use hardcoded secrets in production)
$sharedSecret = ConvertTo-SecureString -String "MyTestSecretKey" -AsPlainText -Force
# Verify signature
if (-not (Test-HmacAuthorization -SharedSecret $sharedSecret -WebhookData $WebhookData)) {
Write-Output "❌ Signature invalid."
} else {
Write-Output "✅ Signature valid."
}
# Dispose secret
[void]$sharedSecret.Dispose()
.FUNCTIONALITY
Security, HMAC Authentication
.NOTES
Author: Julian Pawlowski
Company Name: Workoho GmbH
Created: 2025-03-17
Updated: 2026-03-16
#>
param(
# The shared secret key used for HMAC calculation (SecureString)
[Parameter(Mandatory)][securestring]$SharedSecret,
# The full webhook request data object passed by Azure Automation (includes headers and body)
[Parameter(Mandatory)][object]$WebhookData,
# Allowed time difference in minutes for timestamp validation (default: 5 minutes)
[int]$AllowedTimeDriftMinutes = 5,
# Expected request path (used in canonical message construction)
[string]$ExpectedPath = '/webhooks',
# Expected base domain pattern for the Host header.
# Default matches Azure Automation webhook hosts: *.webhook.<region>.azure-automation.net
[string]$ExpectedHostPattern = '\.webhook\.[a-z0-9-]+\.azure-automation\.net$'
)
$allowedAlgorithms = @('HMACSHA256', 'HMACSHA512')
$headers = $WebhookData.RequestHeader
$authHeader = $headers.'x-authorization'
if (-not $authHeader) {
Write-Error 'Missing x-authorization header'
return $false
}
if ($authHeader -notmatch '^HMAC-(?<Algorithm>[A-Z0-9]+)\s+SignedHeaders=(?<SignedHeaders>[^&]+)&Signature=(?<Signature>.+)$') {
Write-Error 'Invalid x-authorization header format'
return $false
}
$algorithm = "HMAC$($matches['Algorithm'])"
if ($allowedAlgorithms -notcontains $algorithm) {
Write-Error "Algorithm $algorithm not allowed"
return $false
}
$signedHeaders = $matches['SignedHeaders'].Split(';')
$receivedHmac = $matches['Signature']
foreach ($header in $signedHeaders) {
if ([string]::IsNullOrEmpty($headers.$header)) {
Write-Error "Missing signed header: $header"
return $false
}
}
# Host header check
if ([string]::IsNullOrEmpty($headers.'Host')) {
Write-Error 'Host header required'
return $false
}
# Validate Host header matches Azure Automation webhook domain pattern
if ($headers.'Host' -notmatch $ExpectedHostPattern) {
Write-Error "Host header '$($headers.'Host')' does not match expected pattern '$ExpectedHostPattern'"
return $false
}
# Timestamp freshness check
if ([string]::IsNullOrEmpty($headers.'x-ms-date')) {
Write-Error 'x-ms-date header required'
return $false
}
else {
try {
$requestTime = [datetime]::Parse($headers.'x-ms-date').ToUniversalTime()
$currentTime = (Get-Date).ToUniversalTime()
if ([math]::Abs(($currentTime - $requestTime).TotalMinutes) -gt $AllowedTimeDriftMinutes) {
Write-Error 'Request timestamp expired'
return $false
}
}
catch {
Write-Error 'Invalid timestamp format'
return $false
}
}
# Body hash check
# FIX: Properly dispose SHA256 object to avoid resource leak
if ([string]::IsNullOrEmpty($headers.'x-ms-content-sha256')) {
Write-Error "x-ms-content-sha256 header required"
return $false
}
else {
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($WebhookData.RequestBody)
$sha256 = $null
try {
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$computedBodyHash = [Convert]::ToBase64String($sha256.ComputeHash($bodyBytes))
}
finally {
if ($null -ne $sha256) { $sha256.Dispose() }
}
if ($headers.'x-ms-content-sha256' -ne $computedBodyHash) {
Write-Error "Content hash mismatch"
return $false
}
}
# Nonce check
if ([string]::IsNullOrEmpty($headers.'x-ms-nonce')) {
Write-Error "x-ms-nonce header required"
return $false
}
# Optional future: Implement nonce storage to prevent re-use attacks
# Canonical message construction
$method = 'POST'
$path = $ExpectedPath
$headerValues = foreach ($header in $signedHeaders) { $headers.$header }
$canonicalMessage = "$method`n$path`n" + ($headerValues -join ';')
# FIX: Use byte[] for secret handling instead of intermediate string.
# .NET strings are immutable and cannot be reliably cleared from memory.
# We use SecureStringToBSTR → PtrToStringBSTR → char[] → byte[] and
# clear every mutable buffer in the finally block.
# PtrToStringBSTR does produce an intermediate .NET string (unavoidable
# without platform-specific BSTR length tricks), but we immediately copy
# into a char[] and let the string become GC-eligible. This is the best
# cross-platform compromise that works on Windows, macOS, and Linux.
$secretBytes = $null
$secretChars = $null
$bstr = [IntPtr]::Zero
try {
$hmac = New-Object ("System.Security.Cryptography.$algorithm")
# SecureString → BSTR → string → char[] → byte[]
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SharedSecret)
$plainSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
$secretChars = $plainSecret.ToCharArray()
$plainSecret = $null # release reference; string itself is GC-eligible now
$secretBytes = [Text.Encoding]::UTF8.GetBytes($secretChars)
$hmac.Key = $secretBytes
$computedHmac = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonicalMessage)))
}
finally {
# Zero out the BSTR (cross-platform safe)
if ($bstr -ne [IntPtr]::Zero) {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
# Zero out the intermediate char array
if ($null -ne $secretChars) {
[Array]::Clear($secretChars, 0, $secretChars.Length)
$secretChars = $null
}
# Zero out the secret bytes
if ($null -ne $secretBytes) {
[Array]::Clear($secretBytes, 0, $secretBytes.Length)
$secretBytes = $null
}
# Cleanup HMAC object
if ($null -ne $hmac) {
if ($null -ne $hmac.Key) {
[Array]::Clear($hmac.Key, 0, $hmac.Key.Length)
}
$hmac.Dispose()
$hmac = $null
}
}
# Final comparison (constant-time)
return [System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals(
[Text.Encoding]::UTF8.GetBytes($computedHmac),
[Text.Encoding]::UTF8.GetBytes($receivedHmac)
)
}
function Get-HmacSignedHeaders {
<#
.SYNOPSIS
Generates signed headers for HMAC authentication for Azure Automation webhook requests.
.DESCRIPTION
Generates signed headers for HMAC authentication based on a shared secret (provided as SecureString), webhook URL, and request body content.
Includes timestamp, nonce, and content hash, all covered in the HMAC signature.
SecureString is converted as late as possible and securely cleared after use.
This function is intended to be used when calling Azure Automation webhooks or other APIs requiring signed requests for enhanced security.
------------------------------------------
🔐 Secure Shared Secret Retrieval Options:
------------------------------------------
Always retrieve secrets securely from encrypted sources, ensuring they are stored and used as SecureStrings:
1️⃣ Azure Key Vault (Recommended for cloud/hybrid environments)
Connect-AzAccount -Identity
$sharedSecret = (Get-AzKeyVaultSecret -VaultName "<YourVaultName>" -Name "HmacSharedSecret").SecretValue
2️⃣ Azure Automation Encrypted Variable (inside Automation Account only)
$sharedSecret = (Get-AutomationVariable -Name "SharedSecret") | ConvertTo-SecureString -AsPlainText -Force
3️⃣ Windows Credential Manager (if running on Windows, via SecretManagement module)
Import-Module Microsoft.PowerShell.SecretManagement
$sharedSecret = (Get-StoredCredential -Target "HmacSharedSecret").Password
4️⃣ Encrypted local file (protected by ACLs; not recommended for cloud, acceptable in controlled environments)
$sharedSecret = (Get-Content -Path "C:\Secrets\HmacSecret.txt" -Raw) | ConvertTo-SecureString -AsPlainText -Force
❌ For demonstration purposes only (NEVER hardcode secrets in production):
$sharedSecret = ConvertTo-SecureString -String "MySuperSecretKey" -AsPlainText -Force
.EXAMPLE
# Example usage:
$sharedSecret = (Get-AutomationVariable -Name "SharedSecret") | ConvertTo-SecureString -AsPlainText -Force
$webhookUrl = "https://<your-webhook-url>/webhooks"
$body = '{"param1":"value1"}'
$headers = Get-HmacSignedHeaders -SharedSecret $sharedSecret `
-WebhookUrl $webhookUrl `
-Body $body
[void]$sharedSecret.Dispose()
Invoke-RestMethod -Method POST `
-Uri $webhookUrl `
-Headers $headers `
-Body $body `
-ContentType 'application/json; charset=utf-8'
.FUNCTIONALITY
Security, HMAC Authentication
.NOTES
Author: Julian Pawlowski
Company Name: Workoho GmbH
Created: 2025-03-17
Updated: 2026-03-16
#>
param(
# Shared secret used for HMAC signature (SecureString)
[Parameter(Mandatory)][securestring]$SharedSecret,
# Full webhook URL to which the request will be sent
[Parameter(Mandatory)][string]$WebhookUrl,
# Request body content as a string (usually JSON or similar)
[Parameter(Mandatory)][string]$Body,
# Algorithm to use for HMAC signature (default: HMACSHA256)
[string]$Algorithm = "HMACSHA256",
# Optional: Provide custom nonce (for testing/debugging); defaults to a new random GUID if omitted
[string]$Nonce
)
if ($Algorithm -notin @('HMACSHA256', 'HMACSHA512')) {
throw "Unsupported algorithm: $Algorithm"
}
# Parse URL components
$uri = [System.Uri]$WebhookUrl
$method = "POST"
$path = $uri.AbsolutePath
$webHost = $uri.Host
# Timestamp header (RFC1123 format)
$date = (Get-Date).ToUniversalTime().ToString("R")
# FIX: Properly dispose SHA256 object to avoid resource leak
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body)
$sha256 = $null
try {
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$contentHash = [Convert]::ToBase64String($sha256.ComputeHash($bodyBytes))
}
finally {
if ($null -ne $sha256) { $sha256.Dispose() }
}
# Generate nonce if not provided
if (-not $Nonce) {
$Nonce = [Guid]::NewGuid().ToString()
}
# Define signed headers and order
$signedHeadersList = @('x-ms-date', 'Host', 'x-ms-content-sha256', 'x-ms-nonce')
$signedHeaders = ($signedHeadersList -join ';')
# Build canonical message
$canonicalMessage = "$method`n$path`n$date;$webHost;$contentHash;$Nonce"
# FIX: Use byte[] for secret handling instead of intermediate string.
# .NET strings are immutable and cannot be reliably cleared from memory.
# We use SecureStringToBSTR → PtrToStringBSTR → char[] → byte[] and
# clear every mutable buffer in the finally block.
# PtrToStringBSTR does produce an intermediate .NET string (unavoidable
# without platform-specific BSTR length tricks), but we immediately copy
# into a char[] and let the string become GC-eligible. This is the best
# cross-platform compromise that works on Windows, macOS, and Linux.
$secretBytes = $null
$secretChars = $null
$bstr = [IntPtr]::Zero
try {
$hmac = New-Object ("System.Security.Cryptography.$Algorithm")
# SecureString → BSTR → string → char[] → byte[]
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SharedSecret)
$plainSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
$secretChars = $plainSecret.ToCharArray()
$plainSecret = $null # release reference; string itself is GC-eligible now
$secretBytes = [Text.Encoding]::UTF8.GetBytes($secretChars)
# Set key and compute signature
$hmac.Key = $secretBytes
$signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonicalMessage)))
}
finally {
# Zero out the BSTR (cross-platform safe)
if ($bstr -ne [IntPtr]::Zero) {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
# Zero out the intermediate char array
if ($null -ne $secretChars) {
[Array]::Clear($secretChars, 0, $secretChars.Length)
$secretChars = $null
}
# Zero out the secret bytes
if ($null -ne $secretBytes) {
[Array]::Clear($secretBytes, 0, $secretBytes.Length)
$secretBytes = $null
}
# Cleanup HMAC object
if ($null -ne $hmac) {
if ($null -ne $hmac.Key) {
[Array]::Clear($hmac.Key, 0, $hmac.Key.Length)
}
$hmac.Dispose()
$hmac = $null
}
}
# Prepare headers (Host excluded intentionally)
$headers = @{
'x-ms-date' = $date
'x-ms-content-sha256' = $contentHash
'x-ms-nonce' = $Nonce
'x-authorization' = "HMAC-$($Algorithm.Replace('HMAC','')) SignedHeaders=$signedHeaders&Signature=$signature"
}
return $headers
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment