This is post 2 of the REST APIs with PowerShell mini-series. Post 1 built the resilient REST wrapper; everything here layers auth onto that base. Later posts cover parallel and bulk request patterns, testing REST clients, and observability. PowerShell 7.4+ throughout.
The auth section in the base post is exactly one function: Get-BearerToken with OAuth client credentials. That's fine for tutorial code. In production it's the least interesting auth mode and often the wrong one. Interactive users need authorization-code + PKCE; headless CLIs need device code; federated automation needs JWT client assertion; some APIs (AWS, DocuSign, several banks) sign every request with HMAC; internal services increasingly use mTLS; and if you're running on Azure/AWS/GCP, managed identity means you don't need a credential at all.
Each mode has a different failure surface. This post walks through the six that cover most production APIs, with runnable PowerShell for each, layered on top of Invoke-ResilientRestMethod from the base post.
Pattern 1 Authorization Code with PKCE
The right pattern when a human has to interactively grant your script access (their consent, their token, their scopes). Client secrets don't fit; PKCE (Proof Key for Code Exchange) is a small extension that lets a public client complete the flow without one.
The shape:
- Generate a random
code_verifierand its SHA-256code_challenge. - Open the user's browser to the authorization endpoint with the challenge.
- Serve a tiny local HTTP listener that catches the redirect.
- Trade the redirect's
code+ yourverifierfor an access token.
function Get-InteractiveAuthCodeToken
{
<#
.SYNOPSIS
Runs OAuth 2.0 Authorization Code with PKCE against a supplied provider.
.DESCRIPTION
Opens the default browser to the authorization endpoint, catches the
redirect on a loopback listener, and exchanges the returned code for
an access token. No client secret required - PKCE is the credential.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true)]
[Uri]
$AuthorizationEndpoint,
[Parameter(Mandatory = $true)]
[Uri]
$TokenEndpoint,
[Parameter(Mandatory = $true)]
[string]
$ClientId,
[Parameter(Mandatory = $true)]
[string[]]
$Scopes,
[Parameter()]
[int]
$LoopbackPort = 8765
)
begin
{
Write-Verbose -Message '[Get-InteractiveAuthCodeToken] Begin'
}
process
{
# 1. PKCE verifier + challenge
$bytes = [byte[]]::new(32)
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
$verifier = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_')
$verifierBytes = [System.Text.Encoding]::ASCII.GetBytes($verifier)
$challengeBytes = [System.Security.Cryptography.SHA256]::HashData($verifierBytes)
$challenge = [Convert]::ToBase64String($challengeBytes).TrimEnd('=').Replace('+','-').Replace('/','_')
$state = [Guid]::NewGuid().ToString('N')
$redirectUri = 'http://localhost:{0}/callback' -f $LoopbackPort
# 2. Start the loopback listener before opening the browser
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add(('http://localhost:{0}/' -f $LoopbackPort))
try
{
$listener.Start()
}
catch [System.Net.HttpListenerException]
{
throw ('Could not bind loopback on port {0}: {1}. Try a different -LoopbackPort.' -f $LoopbackPort, $_.Exception.Message)
}
# 3. Open the browser
$authQuery = @{
client_id = $ClientId
response_type = 'code'
redirect_uri = $redirectUri
scope = ($Scopes -join ' ')
state = $state
code_challenge = $challenge
code_challenge_method = 'S256'
}
$qs = ($authQuery.GetEnumerator() | ForEach-Object {
'{0}={1}' -f $_.Key, [Uri]::EscapeDataString($_.Value)
}) -join '&'
$browserUrl = '{0}?{1}' -f $AuthorizationEndpoint, $qs
try
{
Start-Process -FilePath $browserUrl -ErrorAction Stop
}
catch
{
$listener.Stop()
throw ('Could not open the browser: {0}. Copy this URL manually: {1}' -f $_.Exception.Message, $browserUrl)
}
# 4. Wait for the redirect
try
{
$context = $listener.GetContext()
$qsIn = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query)
$code = $qsIn['code']
$returnedState = $qsIn['state']
$error = $qsIn['error']
# Respond so the browser tab shows something friendly
$responseText = if ($error)
{
'Authentication failed: {0}. You can close this tab.' -f $error
}
else
{
'Authentication complete. You can close this tab.'
}
$bytesOut = [System.Text.Encoding]::UTF8.GetBytes($responseText)
$context.Response.OutputStream.Write($bytesOut, 0, $bytesOut.Length)
$context.Response.Close()
if ($error)
{
throw ('Authorization endpoint returned error: {0}' -f $error)
}
if ($returnedState -ne $state)
{
throw ('State mismatch - potential CSRF. Expected {0}, got {1}.' -f $state, $returnedState)
}
if (-not $code)
{
throw 'No authorization code returned.'
}
}
finally
{
$listener.Stop()
$listener.Close()
}
# 5. Trade the code for a token
$tokenBody = @{
grant_type = 'authorization_code'
code = $code
redirect_uri = $redirectUri
client_id = $ClientId
code_verifier = $verifier
}
try
{
$tokenParams = @{
Method = 'POST'
Uri = $TokenEndpoint
Body = $tokenBody
ContentType = 'application/x-www-form-urlencoded'
MaxAttempts = 4
}
Invoke-ResilientRestMethod @tokenParams
}
catch
{
throw ('Token exchange failed: {0}' -f $_.Exception.Message)
}
}
end
{
Write-Verbose -Message '[Get-InteractiveAuthCodeToken] End'
}
}
Three things this function gets right that most tutorials get wrong:
- The
stateparameter is verified, not just echoed. Without that check, an attacker can attach an authorization code from their session to your redirect, silently swapping tokens. - The loopback listener starts before the browser opens, not after. Race the other way and the redirect fires against a closed port.
- The verifier length exceeds 43 bytes, which is the RFC 7636 minimum. Shorter verifiers are silently rejected by some identity providers.
Pattern 2 Device Code Flow
For CLIs, headless servers, or anything without a browser to redirect to. The user authorises on a second device (typically their phone) by entering a short code the CLI polls in the background until the token appears.
function Get-DeviceCodeToken
{
<#
.SYNOPSIS
Runs OAuth 2.0 Device Authorization Grant and returns a token.
.DESCRIPTION
Prints the verification URL and user code, then polls the token
endpoint on the interval the provider specified. Handles the
standard 'authorization_pending' and 'slow_down' responses.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true)]
[Uri]
$DeviceCodeEndpoint,
[Parameter(Mandatory = $true)]
[Uri]
$TokenEndpoint,
[Parameter(Mandatory = $true)]
[string]
$ClientId,
[Parameter(Mandatory = $true)]
[string[]]
$Scopes,
[Parameter()]
[int]
$TimeoutSec = 600
)
begin
{
Write-Verbose -Message '[Get-DeviceCodeToken] Begin'
}
process
{
# 1. Ask for a device code
$deviceBody = @{
client_id = $ClientId
scope = ($Scopes -join ' ')
}
try
{
$codeParams = @{
Method = 'POST'
Uri = $DeviceCodeEndpoint
Body = $deviceBody
ContentType = 'application/x-www-form-urlencoded'
MaxAttempts = 4
}
$device = Invoke-ResilientRestMethod @codeParams
}
catch
{
throw ('Device code request failed: {0}' -f $_.Exception.Message)
}
Write-Host ''
Write-Host (' Verification URL: {0}' -f $device.verification_uri) -ForegroundColor Cyan
Write-Host (' User code: {0}' -f $device.user_code) -ForegroundColor Cyan
Write-Host ''
# 2. Poll for the token
$pollInterval = if ($device.interval) { [int]$device.interval } else { 5 }
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline)
{
Start-Sleep -Seconds $pollInterval
$tokenBody = @{
grant_type = 'urn:ietf:params:oauth:grant-type:device_code'
device_code = $device.device_code
client_id = $ClientId
}
try
{
$tokenParams = @{
Method = 'POST'
Uri = $TokenEndpoint
Body = $tokenBody
ContentType = 'application/x-www-form-urlencoded'
MaxAttempts = 1 # We do our own polling loop; no retry inside.
}
return Invoke-ResilientRestMethod @tokenParams
}
catch [Microsoft.PowerShell.Commands.HttpResponseException]
{
# Try to read the OAuth error out of the body.
$errorBody = try { $_.ErrorDetails.Message | ConvertFrom-Json } catch { $null }
$errorCode = if ($errorBody) { $errorBody.error } else { '' }
switch ($errorCode)
{
'authorization_pending'
{
Write-Verbose -Message '[Get-DeviceCodeToken] authorization pending; polling'
continue
}
'slow_down'
{
Write-Verbose -Message '[Get-DeviceCodeToken] slow_down received; increasing interval'
$pollInterval += 5
continue
}
'expired_token'
{
throw 'Device code expired before authorization completed.'
}
'access_denied'
{
throw 'User declined authorization.'
}
default
{
throw ('Token polling failed: {0}' -f $_.Exception.Message)
}
}
}
}
throw 'Device code flow timed out.'
}
end
{
Write-Verbose -Message '[Get-DeviceCodeToken] End'
}
}
slow_down is the response most home-grown device flows ignore. The provider is telling you "you're polling faster than the RFC allows; back off." Ignore it and you'll get rate-limited into expired_token while the user is still typing their password.
Pattern 3 JWT Client Assertion
The "no shared secret" pattern. Instead of client_secret, you sign a JWT with your private key and hand it to the token endpoint. Federated credentials in Entra ID, GitHub OIDC, and CircleCI OIDC all use this shape. The token endpoint validates your signature against a public key it fetches from your JWKS endpoint.
function Get-JwtAssertionToken
{
<#
.SYNOPSIS
Acquires an OAuth token using a signed JWT client assertion.
.DESCRIPTION
Builds a JWT with the standard claims (iss, sub, aud, jti, iat, exp),
signs it with an RSA private key, and exchanges it at the token
endpoint. No client secret required - the signature is the credential.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true)]
[Uri]
$TokenEndpoint,
[Parameter(Mandatory = $true)]
[string]
$ClientId,
[Parameter(Mandatory = $true)]
[Uri]
$Audience,
[Parameter(Mandatory = $true)]
[System.Security.Cryptography.X509Certificates.X509Certificate2]
$SigningCert,
[Parameter()]
[string[]]
$Scopes = @(),
[Parameter()]
[int]
$LifetimeSeconds = 300
)
begin
{
Write-Verbose -Message '[Get-JwtAssertionToken] Begin'
}
process
{
if (-not $SigningCert.HasPrivateKey)
{
throw 'SigningCert must include a private key.'
}
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$header = @{
alg = 'RS256'
typ = 'JWT'
x5t = [Convert]::ToBase64String($SigningCert.GetCertHash()).TrimEnd('=').Replace('+','-').Replace('/','_')
}
$payload = @{
iss = $ClientId
sub = $ClientId
aud = $Audience.ToString()
jti = [Guid]::NewGuid().ToString('N')
iat = $now
nbf = $now
exp = $now + $LifetimeSeconds
}
$encode = {
param($obj)
$json = $obj | ConvertTo-Json -Compress -Depth 5
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
[Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_')
}
$headerEncoded = & $encode $header
$payloadEncoded = & $encode $payload
$signingInput = '{0}.{1}' -f $headerEncoded, $payloadEncoded
$signingBytes = [System.Text.Encoding]::ASCII.GetBytes($signingInput)
try
{
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($SigningCert)
if (-not $rsa)
{
throw 'Certificate does not expose an RSA private key.'
}
$signatureBytes = $rsa.SignData(
$signingBytes,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
}
catch
{
throw ('JWT signing failed: {0}' -f $_.Exception.Message)
}
$signature = [Convert]::ToBase64String($signatureBytes).TrimEnd('=').Replace('+','-').Replace('/','_')
$assertion = '{0}.{1}' -f $signingInput, $signature
$body = @{
grant_type = 'client_credentials'
client_id = $ClientId
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $assertion
}
if ($Scopes.Count -gt 0)
{
$body.scope = ($Scopes -join ' ')
}
try
{
$tokenParams = @{
Method = 'POST'
Uri = $TokenEndpoint
Body = $body
ContentType = 'application/x-www-form-urlencoded'
MaxAttempts = 4
}
Invoke-ResilientRestMethod @tokenParams
}
catch
{
throw ('Token endpoint rejected assertion: {0}' -f $_.Exception.Message)
}
}
end
{
Write-Verbose -Message '[Get-JwtAssertionToken] End'
}
}
Key design choices that trip most first attempts:
nbfandexpare both required by most enterprise IdPs. Omitnbfand Entra ID rejects the assertion with a message that suggests it's a signing problem.- Base64url, not Base64. The trailing
=and the+/characters have to be swapped, or the parser refuses the JWT. - The
x5theader is the SHA-1 thumbprint of the signing cert, base64url-encoded. It tells the IdP which key to use for verification when you have multiple published in JWKS.
Pattern 4 HMAC-Signed Requests
AWS SigV4, DocuSign, Shopify webhooks, some banks. Every request carries a signature computed from the payload, timestamp, and a shared secret. The server recomputes the signature and compares. No token is sent over the wire; the secret never leaves either side.
A simplified variant that's common in internal APIs and a good pedagogical example:
function Invoke-HmacSignedRequest
{
<#
.SYNOPSIS
Sends an HTTP request signed with an HMAC-SHA256 header.
.DESCRIPTION
Computes an HMAC over the canonical string
(verb + path + timestamp + body-hash) using AccessKey/SecretKey,
adds X-Signature and X-Timestamp headers, and delegates to
Invoke-ResilientRestMethod for the actual send.
.OUTPUTS
The response body as returned by the underlying REST call.
#>
[CmdletBinding()]
param
(
[Parameter(Mandatory = $true)]
[ValidateSet('GET','POST','PUT','PATCH','DELETE')]
[string]
$Method,
[Parameter(Mandatory = $true)]
[Uri]
$Uri,
[Parameter(Mandatory = $true)]
[string]
$AccessKey,
[Parameter(Mandatory = $true)]
[System.Security.SecureString]
$SecretKey,
[Parameter()]
[object]
$Body,
[Parameter()]
[hashtable]
$Headers = @{}
)
begin
{
Write-Verbose -Message '[Invoke-HmacSignedRequest] Begin'
}
process
{
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
$bodyString = if ($null -ne $Body)
{
if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 -Compress }
}
else
{
''
}
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyString)
$bodyHash = -join ([System.Security.Cryptography.SHA256]::HashData($bodyBytes) | ForEach-Object { $_.ToString('x2') })
# Canonical string - order + separators matter.
$canonical = ('{0}`n{1}`n{2}`n{3}' -f $Method.ToUpper(), $Uri.AbsolutePath, $timestamp, $bodyHash)
try
{
$secretPlain = [System.Net.NetworkCredential]::new('', $SecretKey).Password
$keyBytes = [System.Text.Encoding]::UTF8.GetBytes($secretPlain)
$messageBytes = [System.Text.Encoding]::UTF8.GetBytes($canonical)
$signatureBytes = [System.Security.Cryptography.HMACSHA256]::HashData($keyBytes, $messageBytes)
$signature = -join ($signatureBytes | ForEach-Object { $_.ToString('x2') })
}
catch
{
throw ('HMAC computation failed: {0}' -f $_.Exception.Message)
}
finally
{
if ($keyBytes) { [Array]::Clear($keyBytes, 0, $keyBytes.Length) }
}
$signedHeaders = $Headers.Clone()
$signedHeaders['X-Access-Key'] = $AccessKey
$signedHeaders['X-Timestamp'] = $timestamp
$signedHeaders['X-Signature'] = $signature
$requestParams = @{
Method = $Method
Uri = $Uri
Headers = $signedHeaders
}
if ($null -ne $Body)
{
$requestParams.Body = $bodyString
}
Invoke-ResilientRestMethod @requestParams
}
end
{
Write-Verbose -Message '[Invoke-HmacSignedRequest] End'
}
}
Two things this pattern hides that everyone eventually gets bitten by:
- Server timestamp skew. Most APIs reject requests where
X-Timestampis outside a 5-minute window. If your VM's clock drifts by 10 minutes, every call fails auth; the error message is usually "invalid signature" not "clock skew." Sync the clock (w32tm,chronyd,ntpd) before you debug the HMAC. - The body hash is over the raw bytes, not the parsed JSON. Add a space, change a key order, and the signature stops matching. That's why
Bodyis serialised once here (compressed) and reused.
Pattern 5 Mutual TLS (mTLS)
Instead of a bearer token in a header, the TLS handshake itself proves the client's identity. The client presents an X.509 certificate; the server verifies it against a trusted CA. Widely used for machine-to-machine communication inside zero-trust networks and by CAs (see the internal CA post).
function Invoke-MtlsRequest
{
<#
.SYNOPSIS
Sends an HTTP request using a client certificate for mTLS.
.DESCRIPTION
Wraps Invoke-ResilientRestMethod with the -Certificate parameter set
from the local cert store (by thumbprint) or a PFX file on disk.
.OUTPUTS
The response body as returned by the underlying REST call.
#>
[CmdletBinding()]
param
(
[Parameter(Mandatory = $true)]
[ValidateSet('GET','POST','PUT','PATCH','DELETE')]
[string]
$Method,
[Parameter(Mandatory = $true)]
[Uri]
$Uri,
[Parameter(Mandatory = $true, ParameterSetName = 'Thumbprint')]
[string]
$CertificateThumbprint,
[Parameter(Mandatory = $true, ParameterSetName = 'Pfx')]
[string]
$PfxPath,
[Parameter(ParameterSetName = 'Pfx')]
[System.Security.SecureString]
$PfxPassword,
[Parameter()]
[hashtable]
$Headers = @{},
[Parameter()]
[object]
$Body
)
begin
{
Write-Verbose -Message '[Invoke-MtlsRequest] Begin'
}
process
{
try
{
$cert = switch ($PSCmdlet.ParameterSetName)
{
'Thumbprint'
{
$stored = Get-ChildItem -Path 'Cert:\CurrentUser\My','Cert:\LocalMachine\My' -ErrorAction SilentlyContinue |
Where-Object { $_.Thumbprint -eq $CertificateThumbprint } |
Select-Object -First 1
if (-not $stored)
{
throw ('Certificate with thumbprint {0} not found in current-user or local-machine My store.' -f $CertificateThumbprint)
}
if (-not $stored.HasPrivateKey)
{
throw ('Certificate {0} has no accessible private key.' -f $CertificateThumbprint)
}
$stored
}
'Pfx'
{
if ($PfxPassword)
{
[System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
$PfxPath,
$PfxPassword,
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet)
}
else
{
[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxPath)
}
}
}
}
catch
{
throw ('Could not load client certificate: {0}' -f $_.Exception.Message)
}
$requestParams = @{
Method = $Method
Uri = $Uri
Headers = $Headers
Certificate = $cert
ErrorAction = 'Stop'
}
if ($null -ne $Body)
{
$requestParams.Body = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 }
$requestParams.ContentType = 'application/json'
}
try
{
# We call Invoke-RestMethod directly here because Invoke-ResilientRestMethod
# does not currently accept a -Certificate parameter. Wrap it if you retry mTLS.
Invoke-RestMethod @requestParams
}
catch [Microsoft.PowerShell.Commands.HttpResponseException]
{
$status = [int]$_.Exception.Response.StatusCode
throw ('mTLS request returned HTTP {0}: {1}' -f $status, $_.Exception.Message)
}
catch
{
throw ('mTLS request failed: {0}' -f $_.Exception.Message)
}
}
end
{
Write-Verbose -Message '[Invoke-MtlsRequest] End'
}
}
A production version would extend Invoke-ResilientRestMethod to accept -Certificate so the retry logic applies to mTLS too the sketch above kept it separate for clarity. In practice the retry-worthy failure modes (transport reset, 429, 503) are identical.
Pattern 6 Managed Identity
If you run on Azure VMs, Azure Arc, App Service, or Functions and target Azure APIs, you don't need a credential at all. The platform provisions an identity for the VM and exposes a metadata endpoint on 169.254.169.254 (or http://localhost:40342 on Arc) that returns tokens on request. AWS and GCP have equivalents (http://169.254.169.254/latest/meta-data/iam/security-credentials/ and the metadata server respectively).
function Get-AzureManagedIdentityToken
{
<#
.SYNOPSIS
Fetches an access token from the Azure Instance Metadata Service.
.DESCRIPTION
Works on Azure VMs, Arc-enabled servers, App Service, and Functions.
The identity is provisioned by the platform - no client secret,
certificate, or interactive login. Use for calls to Azure ARM,
Key Vault, Storage, etc.
.PARAMETER Resource
The audience URL (e.g. https://management.azure.com,
https://vault.azure.net).
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true)]
[Uri]
$Resource,
[Parameter()]
[string]
$ClientId
)
begin
{
Write-Verbose -Message '[Get-AzureManagedIdentityToken] Begin'
}
process
{
# Prefer the App Service / Functions env var if present; fall back to IMDS.
$identityEndpoint = $env:IDENTITY_ENDPOINT
$identityHeader = $env:IDENTITY_HEADER
$headers = @{}
$uri = $null
if ($identityEndpoint -and $identityHeader)
{
$uri = '{0}?resource={1}&api-version=2019-08-01' -f $identityEndpoint, [Uri]::EscapeDataString($Resource)
$headers['X-IDENTITY-HEADER'] = $identityHeader
}
else
{
# IMDS (VMs, VMSS, Arc)
$uri = 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={0}' -f [Uri]::EscapeDataString($Resource)
$headers['Metadata'] = 'true'
}
if ($ClientId)
{
$uri += '&client_id={0}' -f [Uri]::EscapeDataString($ClientId)
}
try
{
# IMDS occasionally returns 429/500 during provisioning - lean on the resilient wrapper.
$tokenParams = @{
Method = 'GET'
Uri = $uri
Headers = $headers
MaxAttempts = 6
BaseDelayMs = 300
TimeoutSec = 5
}
Invoke-ResilientRestMethod @tokenParams
}
catch
{
throw ('Managed identity token acquisition failed: {0}. Are you sure this host has a managed identity assigned?' -f $_.Exception.Message)
}
}
end
{
Write-Verbose -Message '[Get-AzureManagedIdentityToken] End'
}
}
Two operational realities that surprise people the first time they use IMDS:
- The 169.254 address is not routable across the network. Testing an "IMDS-using" script on your laptop won't work; you have to run it on the actual Azure host. Local dev needs the
IDENTITY_ENDPOINTvariant with Azure CLI's built-in emulator. - IMDS is rate-limited. Calling it in a tight loop from a hundred parallel branches will start returning 429s. Cache the token per-process just like OAuth in the base post.
Choosing the Right Pattern
| Scenario | Right pattern |
|---|---|
| Human running an interactive script | Authorization code + PKCE |
| Headless CLI a human occasionally uses | Device code |
| CI running against your own APIs | JWT client assertion (federated) or client credentials |
| CI running against AWS/GCP/DocuSign | HMAC-signed |
| Zero-trust internal service mesh | mTLS |
| Azure VM / Arc / App Service | Managed identity |
| Legacy internal API | Bearer via client credentials (base post) |
Two rules that shorten the decision:
- If the platform gives you an identity, take it. Managed identity beats every other pattern on operational overhead.
- If you're storing a
client_secret, treat it as a temporary shim and plan the migration to certificate or federated credentials. Secrets rot in production.
Gotchas Earned the Hard Way
- Token caching across parallel branches. The base post's
$script:BearerTokenCacheis per-runspace. AForEach-Object -Parallelblock sees an empty cache and refetches per branch. Post 3 (parallel patterns) handles this properly. - Refresh tokens expire silently on inactivity. Long-lived idle scripts wake up to a rejected refresh; treat "auth failed after months" as an expected event and re-prompt or re-exchange rather than crashing.
Invoke-RestMethod -Certificateon Linux ignores unnamed local stores. You have to load the cert from a PFX explicitly on non-Windows.- Device code flow's
intervalfield is minimum, not exact. Providers can respond withslow_downat any point; the client must obey. Fixed-interval polling gets you banned. - HMAC canonical strings differ per provider. The AWS SigV4 spec is 40 pages for a reason. If you're implementing against a specific API, follow the vendor's canonicalisation examples verbatim; don't reinvent it.
X-Timestampin HMAC signing must be recent. Retrying a signed request more than 5 minutes later needs a fresh signature the original timestamp is dead.Invoke-ResilientRestMethodcan't help; the signer has to re-run.- Managed identity tokens are opaque JWTs. They already carry expiry; parse it and cache accordingly. Refetching per call adds ~50 ms per request and can trigger IMDS rate limits.
What to Do Next
Six patterns cover almost every real-world API. The choice depends on who's calling (human vs. machine), where the caller runs (browser, CI, cloud VM), and what the API vendor supports. The failure surface is different for each, but the retry/observability/testing story is the same as in the base post all of these functions delegate to Invoke-ResilientRestMethod.
Three concrete moves for the next auth-touching script you write:
- Ask "who is calling and where does it run?" first. The answer usually eliminates four of the six patterns. Interactive human → auth-code + PKCE. Azure VM → managed identity. CI in GitHub → JWT client assertion via OIDC. Legacy on-prem → client credentials. Once you know which pattern, the rest of the code is boilerplate.
- Move any script currently using
client_secretto a certificate or federated credential. The migration is one function swap and a config change. The security posture change is enormous secrets get committed, cached, and shared; certificates and federated IDs don't. - Add clock-skew monitoring to any HMAC-signed integration. A cron job that fetches an unauthenticated endpoint, checks the returned
Dateheader against the local clock, and pages if the delta exceeds two minutes. HMAC's "invalid signature" errors are impossible to debug without this signal.
Pairs naturally with the secret management post (which is the right home for the client secrets, PFX passwords, and access keys that appear in this post) and the internal CA post (which is what issues the client certs for mTLS). The next post in this series is parallel and bulk REST patterns throughput, backpressure, and per-branch token caches.


