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:
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:
- 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.
- 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:
-
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.
-
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.
-
Signature verification. Only after the previous checks pass does Test-Jwt verify the signature against the signing input.
-
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-Jwt → ToString() → 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-JwtKey → ConvertFrom-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
Creation
Parsing
Inspection
Validation
Keys
Tests
Documentation
Follow-up (separate issues)
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
GitHubmodule or must implement JWT handling from scratch. The JWT creation and signing logic in theGitHubmodule is general-purpose and not specific to the GitHub API.Request
Desired capability
A cohesive set of public functions in the
Jwtmodule 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:exp,nbf,iat,iss,aud)All public functions use
Jwtas the noun prefix. Anything that does not naturally takeJwtas its noun is private (Base64URL utilities, internal signing primitives, JSON canonicalization helpers).The
GitHubmodule currently contains JWT creation logic in the following files:GitHubmoduleGitHubJWTComponentNew-GitHubUnsignedJWTheader.payload)Add-GitHubLocalJWTSignatureAdd-GitHubKeyVaultJWTSignatureAcceptance criteria
Jwtnoun prefixNew-Jwtcreates 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.SYNOPSIS,.DESCRIPTION, at least one.EXAMPLE, and parameter descriptionsConvertFrom-Jwtreturns a[Jwt]object that round-trips withNew-Jwt— the encoded form survives parse → re-emit unchangedTest-Jwtreturns$true/$falsefor signature + claim validation, with-Detailedswitch returning a structured result explaining which check failedTest-Jwtrejects algorithm-confusion attacks — see Signature validation rulesConvertFrom-Jwt,Test-Jwt,Get-JwtHeader,Get-JwtPayload, andGet-JwtClaimSystem.Security.Cryptography) — no third-party dependenciesConvertTo-Json) and uses-Depth 100so nested claim objects are not silently truncated by PowerShell's default depth of 2GitHubmoduleOut of scope
jwks_uri(network I/O) — tracked separatelyNew-Jwt— consumers that need Key Vault signing callNew-Jwt -Unsignedand attach the signature themselves or via a wrapper functionTechnical 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()andImportFromEncryptedPem()(.NET 5+)[System.Security.Cryptography.ECDsa]::ImportFromPem()(.NET 5+)[System.Buffers.Text.Base64Url](.NET 9+) for Base64URL encoding/decoding without manual padding/replace logicConvertTo-Json -EnumsAsStringsand depth defaults that behave consistently[ordered]hashtable enumeration order preserved throughConvertTo-JsonreliablySystem.Text.Jsonsource-generated paths where usefulThe manifest declares
PowerShellVersion = '7.6'andCompatiblePSEditions = @('Core'). CI matrix runs on PowerShell 7.6 andlatestonly — no 5.1 leg.Public function surface
Every public function uses an approved verb and
Jwt(or aJwt-rooted noun likeJwtHeader,JwtPayload,JwtClaim,JwtKey) as the noun.New-Jwt-Unsignedswitch producesheader.payload.with an empty signature so signing can happen externallyConvertFrom-Jwtheader.payload.signature) into a typed[Jwt]object (no validation)Test-Jwtexp,nbf,iat,iss,aud) of a JWTGet-JwtHeader[Jwt]Get-JwtPayload[Jwt]Get-JwtClaimConvertFrom-JwtKeyAsymmetricAlgorithm/HMACinstance suitable for signing or verificationConvertTo-JwtKeyRSA,ECDsa, byte[]) into a JWK representationNew-JwtdesignNew-Jwtis intentionally basic. It has two modes:-Key(an RSA PEM string, a[byte[]]HMAC secret, or[securestring]wrapping either), builds the unsignedheader.payload, signs it with the requested algorithm, and returns a complete[Jwt]object.-Unsignedswitch) — buildsheader.payloadand returns a[Jwt]object with an emptySignatureproperty. 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()returnsheader.payload.(trailing dot, empty signature segment) until the signature is attached.Parameters:
-Header[hashtable]algandtypare set automatically; passkidor custom JOSE fields here-Payload[hashtable]iss,sub,aud,exp,nbf,iat,jti) are recognized; everything else flows through as private claims-Key[object](signed set)-Algorithm: PEM[string](or[securestring]wrapping a PEM) forRS256/ES256;[byte[]](or[securestring]/[string]raw secret) forHS256-Unsigned[switch](unsigned set)-Algorithm[ValidateSet('RS256','HS256','ES256')][string]RS256[Jwt]mutability modelThe
[Jwt]class is a plain typed container, not a self-validating object:EncodedHeaderandEncodedPayloadare 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].Signatureis a plain[string]property. Setting it (e.g.,$jwt.Signature = $externalSignatureafterNew-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 ofTest-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-Jwtpopulates 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.
JwtHeader,Payload,Signature,EncodedHeader,EncodedPayload.ToString()returnsheader.payload.signature.SigningInput()returnsheader.payloadfor external signingJwtHeaderalg,typ,kid, plus an extension bag (AdditionalFields) for custom fieldsJwtPayloadiss,sub,aud(typed[object], see below),exp,nbf,iat,jti, plusAdditionalFieldsextension bag for private claimsJwtKeyJwtKeyproperties belowJwtBase64Urlsrc/classes/private/)RSA,ECDsa,HMACSystem.Security.Cryptographyaudclaim shapeRFC 7519 §4.1.3 allows
audto be either a singleStringOrURIor an array of them. The module models it as follows:JwtPayload.audis 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 -Audienceaccepts[string[]]. Validation passes if at least one of the supplied audiences appears in the token'saudvalue (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.JwtKeypropertiesThe class exposes one strongly-typed property per RFC 7517 §4 and RFC 7518 §6 field, plus an
AdditionalFieldsextension bag:kty,use,key_ops,alg,kid,x5u,x5c,x5t,x5t#S256n,e,d,p,q,dp,dq,qi,othkty = "RSA"crv,x,y,dkty = "EC"kkty = "oct"All key-material fields are
[string](Base64URL-encoded big-endian unsigned integers per RFC 7518 §2).AdditionalFieldsis[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:
Test-Jwtrejects these by default; explicit-AllowUnsignedswitch required)Additional algorithms (RS384, RS512, PS256, ES384, ES512) are deferred to follow-up issues.
Signature validation rules
Test-Jwtenforces the following rules to prevent algorithm-confusion attacks:Algorithm-key compatibility check (first). The algorithm is read from the token header, then validated against the type of
-Keyprovided:alg-KeytypeRS256[System.Security.Cryptography.RSA], RSA PEM[string], or[JwtKey]withkty = "RSA"HS256[byte[]], raw secret[string]/[securestring], or[JwtKey]withkty = "oct"ES256[System.Security.Cryptography.ECDsa], EC PEM[string], or[JwtKey]withkty = "EC"none-AllowUnsignedrequired-Keyis an error whenalg = noneMismatches throw a terminating error before any signature is computed, blocking the classic "HS256 + RSA public key" attack.
algmust be one of the explicitly supported values. Anything else (including unknown values,Nonewith mixed casing, oralgmissing) is rejected.noneis only honored when-AllowUnsignedis also passed.Signature verification. Only after the previous checks pass does
Test-Jwtverify the signature against the signing input.Claim validation. Runs whether the signature was verified or skipped (
-AllowUnsigned). The only signature-related effect on claim validation is that-DetailedreportsSignatureValidatedas$falsewithReason = 'Skipped (unsigned token)'instead of$true.-Detailedoutput shapeWhen
-Detailedis passed,Test-Jwtreturns a[pscustomobject]instead of a[bool]:Validis the AND of allChecks[*].Passed.Checksalways contains the same set of entries in the same order so callers can index by name.Get-JwtClaimbehavior-Nameaccepts a single string or an array of strings.$null(no error). This matches PowerShell idioms like accessing a missing hashtable key.[ordered]hashtable keyed by the requested names. Missing names map to$null. This makes the return shape stable regardless of which names exist.-ErrorIfMissingswitch (optional) escalates missing names to a non-terminating error per missing name. Default is silent.Naming flags — functions that do not take
Jwtas the nounPer the request, anything that does not naturally read as
Verb-Jwt*is treated as private:ConvertTo-Base64UrlStringConvertFrom-Base64UrlStringTest-JwtSignatureTest-Jwt; users should callTest-Jwtwhich also validates claimsTest-JwtClaimTest-Jwt; per-claim helpers belong inside the validation pipelinePipeline and parameter shape
[string](compact form), a[Jwt]object, or a[securestring]. Pipeline binding isValueFromPipelineon the token parameter soGet-Content token.txt | ConvertFrom-Jwtworks.[Jwt]object. The[Jwt]class'sToString()produces the compact form for transport;SigningInput()returns theheader.payloadportion for external signing workflows.Test-Jwtfollow RFC 7519 §7.2:-Key,-Issuer,-Audience,-ClockSkew(default[timespan]::FromSeconds(0)),-RequireExpiration(default$true),-AllowUnsigned,-Detailed.JSON serialization rules
All
ConvertTo-Jsoncalls in the module pass-Depth 100and-Compress. The default depth of2would silently truncate nested claim values (e.g., agroupsclaim 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-Jwtwith local RSA key: creates a signed token that verifies with the corresponding public keyNew-Jwt -Unsigned: produces a token with empty signature;SigningInput()available;ToString()ends in.New-Jwtwith HS256 +[byte[]]secretNew-Jwtwith HS256 +[string]secretNew-Jwtwith ES256 + EC PEM keykidplus auto-setalg/typ)Round-trip and parsing
New-Jwt→ToString()→ConvertFrom-Jwtproduces an equivalent objectValidation
$trueexpin the past)nbfin the future)aud = ['a','b'], validator passes when supplied-Audienceincludes'b'nbfslightly in the future, within skew → passesnbffurther in the future than skew → fails-AllowUnsignedwithalg = none: claim validation still runs;-DetailedreportsSignatureValidated = $falsealg = HS256but-Keyis an RSA public key → terminating error before any signature workalgvalue not in the supported set → rejectedalgmissing from header → rejectedKeys
ConvertTo-JwtKey→ConvertFrom-JwtKeyproduces an equivalent .NET key)JwtKeypopulates the correct subset of fields perktyPipeline
ConvertFrom-Jwt,Test-Jwt,Get-JwtHeader,Get-JwtPayload,Get-JwtClaimGet-JwtClaim$nullsilently$nullfor missing-ErrorIfMissingescalates missing names to non-terminating errorsRelationship to other issues
New-Jwtand signing as a separate issue. That scope is now consolidated here.Implementation plan
Classes
JwtBase64Urlclass insrc/classes/private/JwtBase64Url.ps1with staticEncodeandConvertToBase64UrlFormatmethodsJwtHeaderclass insrc/classes/public/JwtHeader.ps1JwtPayloadclass insrc/classes/public/JwtPayload.ps1withaudtyped[object]accepting string or string[]Jwtclass insrc/classes/public/Jwt.ps1withHeader,Payload,Signature,EncodedHeader,EncodedPayloadproperties,SigningInput(), andToString()returning the encoded tokenJwtKeyclass insrc/classes/public/JwtKey.ps1per RFC 7517 §4 with the property set listed inJwtKeypropertiesCreation
New-Jwtinsrc/functions/public/New-Jwt.ps1with parameter sets for signed (-Key, supports RS256 / HS256 / ES256) and unsigned (-Unsignedswitch). AllConvertTo-Jsoncalls use-Depth 100 -Compress.ConvertTo-Base64UrlStringinsrc/functions/private/ConvertTo-Base64UrlString.ps1ConvertFrom-Base64UrlStringinsrc/functions/private/ConvertFrom-Base64UrlString.ps1Parsing
ConvertFrom-Jwtinsrc/functions/public/ConvertFrom-Jwt.ps1— accepts pipeline string input, returns[Jwt], validates segment count and decodes JSON with explicit error messages for malformed inputInspection
Get-JwtHeaderinsrc/functions/public/Get-JwtHeader.ps1Get-JwtPayloadinsrc/functions/public/Get-JwtPayload.ps1Get-JwtClaiminsrc/functions/public/Get-JwtClaim.ps1—-Nameaccepts single string or string[],-ErrorIfMissingswitchValidation
Test-Jwtinsrc/functions/public/Test-Jwt.ps1with parameters-Key,-Issuer,-Audience,-ClockSkew,-RequireExpiration,-AllowUnsigned,-Detailed. Performs the algorithm-key compatibility check before signature verification per Signature validation rules.Test-JwtSignatureinsrc/functions/private/Test-JwtSignature.ps1— RS256 / HS256 / ES256 verificationTest-JwtClaiminsrc/functions/private/Test-JwtClaim.ps1—exp/nbf/iat/iss/audchecks per RFC 7519 §4.1, with array-awareaudmatchingKeys
ConvertFrom-JwtKeyinsrc/functions/public/ConvertFrom-JwtKey.ps1—[JwtKey]→RSA/ECDsa/HMACConvertTo-JwtKeyinsrc/functions/public/ConvertTo-JwtKey.ps1—RSA/ECDsa/ byte[] →[JwtKey]Tests
New-Jwttests per Test strategy → CreationConvertFrom-Jwt,Test-Jwt,Get-Jwt*Get-JwtClaimtests for present/missing/mixed names and-ErrorIfMissingDocumentation
README.mdwith the full public function table and one example per function category (create / parse / inspect / validate / keys), including aNew-Jwt -Unsigned+ external signing exampleFollow-up (separate issues)
Get-JwtKeythat fetches a JWKS from a remotejwks_uri(separate because it introduces network I/O)GitHubmodule to depend onJwtand replace inline JWT logic (tracked in theGitHubrepository)