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:

  1. Generate a random code_verifier and its SHA-256 code_challenge.
  2. Open the user's browser to the authorization endpoint with the challenge.
  3. Serve a tiny local HTTP listener that catches the redirect.
  4. Trade the redirect's code + your verifier for 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 state parameter 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:

  • nbf and exp are both required by most enterprise IdPs. Omit nbf and 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 x5t header 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-Timestamp is 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 Body is 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_ENDPOINT variant 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:BearerTokenCache is per-runspace. A ForEach-Object -Parallel block 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 -Certificate on Linux ignores unnamed local stores. You have to load the cert from a PFX explicitly on non-Windows.
  • Device code flow's interval field is minimum, not exact. Providers can respond with slow_down at 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-Timestamp in HMAC signing must be recent. Retrying a signed request more than 5 minutes later needs a fresh signature the original timestamp is dead. Invoke-ResilientRestMethod can'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:

  1. 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.
  2. Move any script currently using client_secret to 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.
  3. Add clock-skew monitoring to any HMAC-signed integration. A cron job that fetches an unauthenticated endpoint, checks the returned Date header 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.