Skip to content

Implement JWT creation, parsing, validation, inspection, and JWK functions #13

@MariusStorhaug

Description

The Jwt module needs to provide a complete set of PowerShell functions for working with JSON Web Tokens. Today, automation authors who need to create a signed token, inspect a token returned from an identity provider, validate a token received from a client, or extract specific claims from a JWT must either pull in a heavyweight third-party library or hand-roll Base64URL decoding and JSON parsing in their scripts. The module currently only carries a placeholder New-Jwt.

Automation scripts and PowerShell modules that need to authenticate via JWT (e.g., as a GitHub App) currently depend on the full GitHub module or must implement JWT handling from scratch. The JWT creation and signing logic in the GitHub module is general-purpose and not specific to the GitHub API.

Request

Desired capability

A cohesive set of public functions in the Jwt module covering the full JWT lifecycle described in RFC 7519 — JSON Web Token (JWT) and the related JOSE specs it builds on (RFC 7515 — JWS, RFC 7517 — JWK, RFC 7518 — JWA). A user should be able to:

  • Create a JWT from arbitrary headers and claims, optionally signing it with a local key, or producing an unsigned token so the signature can come from elsewhere (e.g., Azure Key Vault, an HSM, or a remote signing service)
  • Parse an encoded JWT string into typed PowerShell objects without verifying the signature
  • Validate a JWT — verifying the signature against a key and checking the registered claims (exp, nbf, iat, iss, aud)
  • Inspect specific parts of a token (header, payload, individual claims) without writing Base64URL decoding by hand
  • Work with JWKs — represent and convert keys in the JSON Web Key format used by OIDC/OAuth identity providers

All public functions use Jwt as the noun prefix. Anything that does not naturally take Jwt as its noun is private (Base64URL utilities, internal signing primitives, JSON canonicalization helpers).

The GitHub module currently contains JWT creation logic in the following files:

Current location in GitHub module Purpose
GitHubJWTComponent Base64URL encoding utilities
New-GitHubUnsignedJWT Creates unsigned JWT (header.payload)
Add-GitHubLocalJWTSignature Signs JWT using local RSA private key
Add-GitHubKeyVaultJWTSignature Signs JWT via Azure Key Vault

Acceptance criteria

  • The module exports the public functions listed in Technical decisions, all using approved PowerShell verbs and the Jwt noun prefix
  • New-Jwt creates a signed JWT when given a private key, or produces an unsigned token (header.payload.) when called with -Unsigned, so the signature can be attached by an external signing process
  • Every function has comment-based help with .SYNOPSIS, .DESCRIPTION, at least one .EXAMPLE, and parameter descriptions
  • ConvertFrom-Jwt returns a [Jwt] object that round-trips with New-Jwt — the encoded form survives parse → re-emit unchanged
  • Test-Jwt returns $true/$false for signature + claim validation, with -Detailed switch returning a structured result explaining which check failed
  • Test-Jwt rejects algorithm-confusion attacks — see Signature validation rules
  • Token strings can be passed via pipeline to ConvertFrom-Jwt, Test-Jwt, Get-JwtHeader, Get-JwtPayload, and Get-JwtClaim
  • All cryptographic operations use the .NET BCL (System.Security.Cryptography) — no third-party dependencies
  • JSON serialization preserves member order so signatures verify deterministically (no implicit reordering by ConvertTo-Json) and uses -Depth 100 so nested claim objects are not silently truncated by PowerShell's default depth of 2
  • The module can be consumed independently without the GitHub module

Out of scope

  • JWE (encrypted tokens, RFC 7516) — tracked separately
  • Fetching JWKS from a remote jwks_uri (network I/O) — tracked separately
  • Algorithms beyond RS256 / HS256 / ES256 in the initial release — additional algorithms tracked in follow-up issues
  • Windows PowerShell 5.1 — see runtime support below
  • Azure Key Vault signing baked into New-Jwt — consumers that need Key Vault signing call New-Jwt -Unsigned and attach the signature themselves or via a wrapper function

Technical decisions

Runtime support

The module targets PowerShell 7.6+ (the current LTS) and newer. Windows PowerShell 5.1 is explicitly not supported. This unlocks:

  • [System.Security.Cryptography.RSA]::ImportFromPem() and ImportFromEncryptedPem() (.NET 5+)
  • [System.Security.Cryptography.ECDsa]::ImportFromPem() (.NET 5+)
  • [System.Buffers.Text.Base64Url] (.NET 9+) for Base64URL encoding/decoding without manual padding/replace logic
  • ConvertTo-Json -EnumsAsStrings and depth defaults that behave consistently
  • [ordered] hashtable enumeration order preserved through ConvertTo-Json reliably
  • Modern System.Text.Json source-generated paths where useful

The manifest declares PowerShellVersion = '7.6' and CompatiblePSEditions = @('Core'). CI matrix runs on PowerShell 7.6 and latest only — no 5.1 leg.

Public function surface

Every public function uses an approved verb and Jwt (or a Jwt-rooted noun like JwtHeader, JwtPayload, JwtClaim, JwtKey) as the noun.

Function Verb category Purpose
New-Jwt Common Create a JWT from header values and a claims hashtable. Signs with a local key by default; -Unsigned switch produces header.payload. with an empty signature so signing can happen externally
ConvertFrom-Jwt Data Parse a compact JWT string (header.payload.signature) into a typed [Jwt] object (no validation)
Test-Jwt Diagnostic Verify the signature and validate the registered claims (exp, nbf, iat, iss, aud) of a JWT
Get-JwtHeader Common Return the parsed header object from a JWT string or [Jwt]
Get-JwtPayload Common Return the parsed payload object from a JWT string or [Jwt]
Get-JwtClaim Common Return the value of one or more named claims from a JWT (supports both registered and private claims)
ConvertFrom-JwtKey Data Convert a JWK into a .NET AsymmetricAlgorithm / HMAC instance suitable for signing or verification
ConvertTo-JwtKey Data Convert a .NET key (RSA, ECDsa, byte[]) into a JWK representation

Note on ConvertTo-Jwt: an earlier draft of this issue listed a ConvertTo-Jwt function for "advanced authoring scenarios". It is removed because New-Jwt -Unsigned covers the same need (compose a [Jwt] from header + payload without producing a signature). Constructing a [Jwt] directly from [JwtHeader] / [JwtPayload] instances is also possible via the class constructor for the rare case where the hashtable input shape is inconvenient.

New-Jwt design

New-Jwt is intentionally basic. It has two modes:

  1. Signed (default) — accepts -Key (an RSA PEM string, a [byte[]] HMAC secret, or [securestring] wrapping either), builds the unsigned header.payload, signs it with the requested algorithm, and returns a complete [Jwt] object.
  2. Unsigned (-Unsigned switch) — builds header.payload and returns a [Jwt] object with an empty Signature property. The consumer is responsible for computing the signature (e.g., via Azure Key Vault, an HSM, or another external service) and attaching it via $jwt.Signature = $externalSignature. ToString() returns header.payload. (trailing dot, empty signature segment) until the signature is attached.

Parameters:

Parameter Type / Set Description
-Header [hashtable] Optional header overrides. alg and typ are set automatically; pass kid or custom JOSE fields here
-Payload [hashtable] Mandatory. The JWT claims. Registered claims (iss, sub, aud, exp, nbf, iat, jti) are recognized; everything else flows through as private claims
-Key [object] (signed set) Signing key. Format depends on -Algorithm: PEM [string] (or [securestring] wrapping a PEM) for RS256 / ES256; [byte[]] (or [securestring] / [string] raw secret) for HS256
-Unsigned [switch] (unsigned set) Produce an unsigned token. Signature must be attached externally
-Algorithm [ValidateSet('RS256','HS256','ES256')] [string] Signing algorithm. Stored in the header regardless of whether the token is signed or unsigned. Defaults to RS256

[Jwt] mutability model

The [Jwt] class is a plain typed container, not a self-validating object:

  • EncodedHeader and EncodedPayload are computed once by the constructor from the supplied [JwtHeader] / [JwtPayload]. They are not recomputed when the underlying objects are mutated. To rebuild from a mutated header/payload, construct a new [Jwt].
  • Signature is a plain [string] property. Setting it (e.g., $jwt.Signature = $externalSignature after New-Jwt -Unsigned) is a raw write — the class does not verify that the signature matches the signing input, that the algorithm matches, or that the value is valid Base64URL. Verification is the job of Test-Jwt.
  • SigningInput() always returns the live "$EncodedHeader.$EncodedPayload" so external signers see the same input the token transports.
  • ToString() always returns "$EncodedHeader.$EncodedPayload.$Signature". With an empty signature this yields a trailing dot, which is the expected wire form for an unsigned token.

This design keeps the class predictable for round-trip use (ConvertFrom-Jwt populates fields directly from the parsed segments without re-encoding) at the cost of trusting the caller to keep the encoded form and the typed objects consistent. Helpers that need to "edit then re-emit" should construct a new [Jwt] rather than mutating an existing one.

Class design

PowerShell classes back the public types. .NET BCL types are used for cryptography; PowerShell classes are used where a typed shape is needed for the JOSE objects.

Class Source Visibility Purpose
Jwt PowerShell Public Holds Header, Payload, Signature, EncodedHeader, EncodedPayload. ToString() returns header.payload.signature. SigningInput() returns header.payload for external signing
JwtHeader PowerShell Public Typed JOSE header — alg, typ, kid, plus an extension bag (AdditionalFields) for custom fields
JwtPayload PowerShell Public Typed payload — iss, sub, aud (typed [object], see below), exp, nbf, iat, jti, plus AdditionalFields extension bag for private claims
JwtKey PowerShell Public JWK representation per RFC 7517 §4 — see JwtKey properties below
JwtBase64Url PowerShell Private (src/classes/private/) Static methods for Base64URL encoding/decoding. Not part of the public surface
RSA, ECDsa, HMAC .NET n/a Used directly for sign/verify via System.Security.Cryptography

aud claim shape

RFC 7519 §4.1.3 allows aud to be either a single StringOrURI or an array of them. The module models it as follows:

  • JwtPayload.aud is typed [object]. On parse it holds a [string] for a single audience or [string[]] for an array. On emit, the value is serialized as-is (a one-element array stays an array; a string stays a string).
  • Test-Jwt -Audience accepts [string[]]. Validation passes if at least one of the supplied audiences appears in the token's aud value (matching the RFC 7519 §4.1.3 rule that the principal must identify itself with a value in the audiences). Comparison is ordinal case-sensitive.

JwtKey properties

The class exposes one strongly-typed property per RFC 7517 §4 and RFC 7518 §6 field, plus an AdditionalFields extension bag:

Field group Properties Used by
Common (RFC 7517 §4) kty, use, key_ops, alg, kid, x5u, x5c, x5t, x5t#S256 All key types
RSA (RFC 7518 §6.3) n, e, d, p, q, dp, dq, qi, oth kty = "RSA"
EC (RFC 7518 §6.2) crv, x, y, d kty = "EC"
Symmetric (RFC 7518 §6.4) k kty = "oct"

All key-material fields are [string] (Base64URL-encoded big-endian unsigned integers per RFC 7518 §2). AdditionalFields is [hashtable] for forward compatibility with future JWK extensions.

Algorithm support

Initial implementation covers the algorithms most commonly seen in OIDC tokens and the existing GitHub module use case:

  • RS256 — RSA + SHA-256 (PKCS#1 v1.5 padding) — primary algorithm
  • HS256 — HMAC + SHA-256 — symmetric tokens for service-to-service
  • ES256 — ECDSA P-256 + SHA-256 — modern asymmetric, common in OIDC
  • none — unsigned tokens (Test-Jwt rejects these by default; explicit -AllowUnsigned switch required)

Additional algorithms (RS384, RS512, PS256, ES384, ES512) are deferred to follow-up issues.

Signature validation rules

Test-Jwt enforces the following rules to prevent algorithm-confusion attacks:

  1. Algorithm-key compatibility check (first). The algorithm is read from the token header, then validated against the type of -Key provided:

    Header alg Required -Key type Rejected examples
    RS256 [System.Security.Cryptography.RSA], RSA PEM [string], or [JwtKey] with kty = "RSA" byte arrays, HMAC keys, EC keys
    HS256 [byte[]], raw secret [string]/[securestring], or [JwtKey] with kty = "oct" RSA keys, EC keys, RSA PEM strings
    ES256 [System.Security.Cryptography.ECDsa], EC PEM [string], or [JwtKey] with kty = "EC" RSA keys, byte arrays, HMAC keys
    none n/a — -AllowUnsigned required any non-empty -Key is an error when alg = none

    Mismatches throw a terminating error before any signature is computed, blocking the classic "HS256 + RSA public key" attack.

  2. alg must be one of the explicitly supported values. Anything else (including unknown values, None with mixed casing, or alg missing) is rejected. none is only honored when -AllowUnsigned is also passed.

  3. Signature verification. Only after the previous checks pass does Test-Jwt verify the signature against the signing input.

  4. Claim validation. Runs whether the signature was verified or skipped (-AllowUnsigned). The only signature-related effect on claim validation is that -Detailed reports SignatureValidated as $false with Reason = 'Skipped (unsigned token)' instead of $true.

-Detailed output shape

When -Detailed is passed, Test-Jwt returns a [pscustomobject] instead of a [bool]:

Valid              : $true / $false   # overall result
SignatureValidated : $true / $false   # $false when -AllowUnsigned was used and alg = none
Algorithm          : RS256            # value from the header
Checks             : @(
    @{ Name = 'Algorithm';   Passed = $true;  Reason = $null }
    @{ Name = 'Signature';   Passed = $true;  Reason = $null }
    @{ Name = 'Expiration';  Passed = $false; Reason = 'Token expired at 2026-01-01T00:00:00Z' }
    @{ Name = 'NotBefore';   Passed = $true;  Reason = $null }
    @{ Name = 'Issuer';      Passed = $true;  Reason = $null }
    @{ Name = 'Audience';    Passed = $true;  Reason = $null }
)

Valid is the AND of all Checks[*].Passed. Checks always contains the same set of entries in the same order so callers can index by name.

Get-JwtClaim behavior

  • -Name accepts a single string or an array of strings.
  • A single name that is not present in the payload returns $null (no error). This matches PowerShell idioms like accessing a missing hashtable key.
  • An array of names returns an [ordered] hashtable keyed by the requested names. Missing names map to $null. This makes the return shape stable regardless of which names exist.
  • -ErrorIfMissing switch (optional) escalates missing names to a non-terminating error per missing name. Default is silent.

Naming flags — functions that do not take Jwt as the noun

Per the request, anything that does not naturally read as Verb-Jwt* is treated as private:

Private function Why not public
ConvertTo-Base64UrlString Base64URL is generic JOSE/RFC 4648, not JWT-specific
ConvertFrom-Base64UrlString Same as above
Test-JwtSignature Called by Test-Jwt; users should call Test-Jwt which also validates claims
Test-JwtClaim Called by Test-Jwt; per-claim helpers belong inside the validation pipeline

Pipeline and parameter shape

  • Functions that consume a token accept either a [string] (compact form), a [Jwt] object, or a [securestring]. Pipeline binding is ValueFromPipeline on the token parameter so Get-Content token.txt | ConvertFrom-Jwt works.
  • Functions that produce a token return a [Jwt] object. The [Jwt] class's ToString() produces the compact form for transport; SigningInput() returns the header.payload portion for external signing workflows.
  • Validation parameters on Test-Jwt follow RFC 7519 §7.2: -Key, -Issuer, -Audience, -ClockSkew (default [timespan]::FromSeconds(0)), -RequireExpiration (default $true), -AllowUnsigned, -Detailed.

JSON serialization rules

All ConvertTo-Json calls in the module pass -Depth 100 and -Compress. The default depth of 2 would silently truncate nested claim values (e.g., a groups claim that is an array of objects), which would change the signing input and break verification. Header and payload field order is preserved by emitting from [ordered] dictionaries.

Test strategy

Pester unit tests covering:

Creation

  • New-Jwt with local RSA key: creates a signed token that verifies with the corresponding public key
  • New-Jwt -Unsigned: produces a token with empty signature; SigningInput() available; ToString() ends in .
  • New-Jwt with HS256 + [byte[]] secret
  • New-Jwt with HS256 + [string] secret
  • New-Jwt with ES256 + EC PEM key
  • Header merging (custom kid plus auto-set alg/typ)
  • Registered claim recognition on the payload
  • Nested claim values (object-valued claim) survive serialization without truncation

Round-trip and parsing

  • New-JwtToString()ConvertFrom-Jwt produces an equivalent object
  • RFC 7519 §3.1 / RFC 7515 Appendix A known-vector tests
  • Malformed input rejection (negative tests):
    • Wrong number of segments (1, 2, 4 dots)
    • Non-Base64URL characters in any segment
    • Valid Base64URL but invalid JSON in the header
    • Valid Base64URL but invalid JSON in the payload
    • Empty header or empty payload after decoding

Validation

  • Valid token (signed, in-window, matching iss/aud) → $true
  • Expired token (exp in the past)
  • Not-yet-valid token (nbf in the future)
  • Wrong issuer
  • Wrong audience (single audience and array audience)
  • Audience array: token has aud = ['a','b'], validator passes when supplied -Audience includes 'b'
  • Signature mismatch (good claims, tampered signature)
  • Clock skew tolerance:
    • Token expired by less than the skew → passes
    • Token expired by more than the skew → fails
    • Token nbf slightly in the future, within skew → passes
    • Token nbf further in the future than skew → fails
  • -AllowUnsigned with alg = none: claim validation still runs; -Detailed reports SignatureValidated = $false
  • Algorithm confusion attack: token header alg = HS256 but -Key is an RSA public key → terminating error before any signature work
  • alg value not in the supported set → rejected
  • alg missing from header → rejected

Keys

  • JWK round-trip for RSA, EC P-256, and HMAC keys (ConvertTo-JwtKeyConvertFrom-JwtKey produces an equivalent .NET key)
  • JwtKey populates the correct subset of fields per kty

Pipeline

  • String token via pipeline to ConvertFrom-Jwt, Test-Jwt, Get-JwtHeader, Get-JwtPayload, Get-JwtClaim

Get-JwtClaim

  • Single name present → returns the value
  • Single name missing → returns $null silently
  • Array of names with mix of present and missing → returns ordered hashtable with $null for missing
  • -ErrorIfMissing escalates missing names to non-terminating errors

Relationship to other issues


Implementation plan

Classes

  • Create JwtBase64Url class in src/classes/private/JwtBase64Url.ps1 with static Encode and ConvertToBase64UrlFormat methods
  • Create JwtHeader class in src/classes/public/JwtHeader.ps1
  • Create JwtPayload class in src/classes/public/JwtPayload.ps1 with aud typed [object] accepting string or string[]
  • Create Jwt class in src/classes/public/Jwt.ps1 with Header, Payload, Signature, EncodedHeader, EncodedPayload properties, SigningInput(), and ToString() returning the encoded token
  • Create JwtKey class in src/classes/public/JwtKey.ps1 per RFC 7517 §4 with the property set listed in JwtKey properties

Creation

  • Implement New-Jwt in src/functions/public/New-Jwt.ps1 with parameter sets for signed (-Key, supports RS256 / HS256 / ES256) and unsigned (-Unsigned switch). All ConvertTo-Json calls use -Depth 100 -Compress.
  • Create private ConvertTo-Base64UrlString in src/functions/private/ConvertTo-Base64UrlString.ps1
  • Create private ConvertFrom-Base64UrlString in src/functions/private/ConvertFrom-Base64UrlString.ps1

Parsing

  • Create ConvertFrom-Jwt in src/functions/public/ConvertFrom-Jwt.ps1 — accepts pipeline string input, returns [Jwt], validates segment count and decodes JSON with explicit error messages for malformed input

Inspection

  • Create Get-JwtHeader in src/functions/public/Get-JwtHeader.ps1
  • Create Get-JwtPayload in src/functions/public/Get-JwtPayload.ps1
  • Create Get-JwtClaim in src/functions/public/Get-JwtClaim.ps1-Name accepts single string or string[], -ErrorIfMissing switch

Validation

  • Create Test-Jwt in src/functions/public/Test-Jwt.ps1 with parameters -Key, -Issuer, -Audience, -ClockSkew, -RequireExpiration, -AllowUnsigned, -Detailed. Performs the algorithm-key compatibility check before signature verification per Signature validation rules.
  • Create private Test-JwtSignature in src/functions/private/Test-JwtSignature.ps1 — RS256 / HS256 / ES256 verification
  • Create private Test-JwtClaim in src/functions/private/Test-JwtClaim.ps1exp / nbf / iat / iss / aud checks per RFC 7519 §4.1, with array-aware aud matching

Keys

  • Create ConvertFrom-JwtKey in src/functions/public/ConvertFrom-JwtKey.ps1[JwtKey]RSA / ECDsa / HMAC
  • Create ConvertTo-JwtKey in src/functions/public/ConvertTo-JwtKey.ps1RSA / ECDsa / byte[] → [JwtKey]

Tests

  • Add New-Jwt tests per Test strategy → Creation
  • Add round-trip and parsing tests per Test strategy → Round-trip and parsing (including malformed-input negative tests)
  • Add validation tests per Test strategy → Validation (including algorithm-confusion attack and bidirectional clock skew)
  • Add JWK round-trip tests for RSA, EC P-256, and HMAC keys
  • Add pipeline binding tests for ConvertFrom-Jwt, Test-Jwt, Get-Jwt*
  • Add Get-JwtClaim tests for present/missing/mixed names and -ErrorIfMissing

Documentation

  • Update README.md with the full public function table and one example per function category (create / parse / inspect / validate / keys), including a New-Jwt -Unsigned + external signing example

Follow-up (separate issues)

  • Add JWE support (RFC 7516)
  • Add Get-JwtKey that fetches a JWKS from a remote jwks_uri (separate because it introduces network I/O)
  • Add additional algorithms (RS384, RS512, PS256, ES384, ES512)
  • Update the GitHub module to depend on Jwt and replace inline JWT logic (tracked in the GitHub repository)

Metadata

Metadata

Labels

FeatureFeature requestsMinorNew features or enhancements

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions