Public/Helpers/New-JWT.ps1

function New-JWT {
    [CmdletBinding(
        SupportsShouldProcess = $true,
        DefaultParameterSetName = 'HMAC'
    )]
    [OutputType([string])]
    Param
    (
        [Parameter(Mandatory = $true)]
        [string]$Audience,

        [Parameter(Mandatory = $true)]
        [string]$Issuer,

        [Parameter(Mandatory = $true)]
        [string]$Subject,

        [Parameter(Mandatory = $true)]
        [int]$ExpirationMinutes,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'HMAC'
        )]
        [string]$SigningKey,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'RSA'
        )]
        [System.Security.Cryptography.RSA]$RSAKey,

        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'RSA'
        )]
        [string]$KeyId,

        [Parameter(Mandatory = $false)]
        [hashtable]$AdditionalClaims
    )

    if ($PSCmdlet.ShouldProcess(
        "Creating a new JWT token"
    )) {
        $isRSA = $PSCmdlet.ParameterSetName -eq 'RSA'

        $header = @{
            alg = if ($isRSA) { 'RS256' } else { 'HS256' }
            typ = 'JWT'
        }
        if ($KeyId) { $header.kid = $KeyId }

        $now = [math]::Floor(
            [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
        )
        $exp = [math]::Floor(
            ([System.DateTimeOffset]::UtcNow.AddMinutes(
                $ExpirationMinutes
            )).ToUnixTimeSeconds()
        )

        $payload = @{
            aud = $Audience
            iss = $Issuer
            iat = $now
            nbf = $now
            exp = $exp
            sub = $Subject
        }

        if ($AdditionalClaims) {
            foreach ($key in $AdditionalClaims.Keys) {
                $payload[$key] = $AdditionalClaims[$key]
            }
        }

        $headerJson = $header | ConvertTo-Json -Compress
        $payloadJson = $payload | ConvertTo-Json -Compress

        if ($isRSA) {
            $headerB64 = ConvertTo-Base64Url -Bytes (
                [System.Text.Encoding]::UTF8.GetBytes(
                    $headerJson
                )
            )
            $payloadB64 = ConvertTo-Base64Url -Bytes (
                [System.Text.Encoding]::UTF8.GetBytes(
                    $payloadJson
                )
            )

            $sigInput = '{0}.{1}' -f $headerB64, $payloadB64
            $sigBytes = $RSAKey.SignData(
                [System.Text.Encoding]::UTF8.GetBytes(
                    $sigInput
                ),
                [System.Security.Cryptography.HashAlgorithmName]::SHA256,
                [System.Security.Cryptography.RSASignaturePadding]::Pkcs1
            )
            $signature = ConvertTo-Base64Url -Bytes $sigBytes

            return '{0}.{1}.{2}' -f `
                $headerB64, $payloadB64, $signature
        }
        else {
            $headerBase64 = [System.Convert]::ToBase64String(
                [System.Text.Encoding]::UTF8.GetBytes(
                    $headerJson
                )
            )
            $payloadBase64 = [System.Convert]::ToBase64String(
                [System.Text.Encoding]::UTF8.GetBytes(
                    $payloadJson
                )
            )

            $signature = [System.Convert]::ToBase64String(
                [System.Security.Cryptography.HMACSHA256]::new(
                    [System.Text.Encoding]::UTF8.GetBytes(
                        $SigningKey
                    )
                ).ComputeHash(
                    [System.Text.Encoding]::UTF8.GetBytes(
                        "$headerBase64.$payloadBase64"
                    )
                )
            )

            $jwt = "$headerBase64.$payloadBase64.$signature"
            return $jwt
        }
    }
<#
.SYNOPSIS
Generates a new JWT with HS256 or RS256 signing.
 
.DESCRIPTION
Creates a new JWT token using HS256 (HMAC-SHA256) or RS256
(RSA-SHA256) algorithm. Supports custom claims via the
AdditionalClaims parameter. RS256 mode uses proper Base64URL
encoding as required by RFC 7515 and the Entra token endpoint.
 
.PARAMETER Audience
The audience (aud) claim. Typically the intended recipient.
 
.PARAMETER Issuer
The issuer (iss) claim. The entity that issued the token.
 
.PARAMETER Subject
The subject (sub) claim. The principal of the token.
 
.PARAMETER ExpirationMinutes
Expiration time in minutes from now.
 
.PARAMETER SigningKey
Secret key for HS256 signing. Required for HMAC parameter set.
 
.PARAMETER RSAKey
RSA key object for RS256 signing. Required for RSA parameter
set. Generate with [System.Security.Cryptography.RSA]::Create().
 
.PARAMETER KeyId
Optional key identifier (kid) added to the JWT header.
Required when the JWKS contains multiple keys.
 
.PARAMETER AdditionalClaims
Hashtable of extra claims to include in the payload.
Example: @{ jti = [guid]::NewGuid().ToString() }
 
.EXAMPLE
New-JWT -Audience "example.com" -Issuer "my-app" `
    -Subject "user123" -ExpirationMinutes 60 `
    -SigningKey "my-secret-key"
 
Generates an HS256-signed JWT token.
 
.EXAMPLE
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
New-JWT -Audience "api://AzureADTokenExchange" `
    -Issuer "https://myissuer.example.com" `
    -Subject "workload-identity" `
    -ExpirationMinutes 10 `
    -RSAKey $rsa -KeyId "key-1" `
    -AdditionalClaims @{ jti = [guid]::NewGuid().ToString() }
 
Generates an RS256-signed JWT for Entra ID federated
identity credential token exchange.
 
.NOTES
    RS256 tokens use Base64URL encoding (RFC 7515).
    HS256 tokens preserve legacy Base64 encoding for
    backward compatibility.
 
.LINK
    MITRE ATT&CK Tactic: TA0006 - Credential Access
    https://attack.mitre.org/tactics/TA0006/
 
.LINK
    MITRE ATT&CK Technique: T1606.002 - Forge Web Credentials
    https://attack.mitre.org/techniques/T1606/002/
#>

}