Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d89673a
Establish JwtBase64Url private class and PowerShell 7.6 baseline
MariusStorhaug May 12, 2026
489ec46
Add Jwt, JwtHeader, JwtPayload, and JwtKey public classes
MariusStorhaug May 12, 2026
0fb050a
Add JWT lifecycle public functions: New-Jwt, ConvertFrom-Jwt, Test-Jw…
MariusStorhaug May 12, 2026
3bec707
Rewrite tests for v2 API including data-driven, parsing, validation, …
MariusStorhaug May 12, 2026
58bbd2a
Document v2 public surface, migration table, and security guarantees …
MariusStorhaug May 12, 2026
99e765c
Preserve input dictionary order in JWT serialization and fix Nullable…
MariusStorhaug May 12, 2026
0e925d3
Add HS384/HS512, RS384/RS512, ES384/ES512, and PS256/PS384/PS512 to J…
MariusStorhaug May 12, 2026
39ee684
Re-expose Base64Url codec helpers and JwtBase64Url class as public su…
MariusStorhaug May 12, 2026
3a96a73
Add JWK Set (RFC 7517 §5) support and RFC 7638 thumbprint computation
MariusStorhaug May 12, 2026
3138969
Fix Get-JwtClaim -ErrorIfMissing to use a stable sentinel for missing…
MariusStorhaug May 12, 2026
4bb5538
Add Pester coverage for full JWS algorithm matrix, RFC 7638 thumbprin…
MariusStorhaug May 12, 2026
b32583b
Document full JWS algorithm coverage, JWK Sets, and RFC 7638 thumbprints
MariusStorhaug May 12, 2026
4300ec7
Merge branch 'main' into feat/13-implement-jwt-module
MariusStorhaug May 21, 2026
49c8c4c
Fix CI lint errors: long lines, missing suppressions, table alignment…
MariusStorhaug May 22, 2026
810a612
Fix Lint-Repository: align README tables (MD060) and suppress TypeNot…
MariusStorhaug May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/linters/.powershell-psscriptanalyzer.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
}
ExcludeRules = @(
'PSMissingModuleManifestField', # This rule is not applicable until the module is built.
'PSUseToExportFieldsInManifest'
'PSUseToExportFieldsInManifest',
'TypeNotFound' # False positive: class cross-references are resolved at runtime, not parse time.
)
}
178 changes: 150 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Jwt

`Jwt` is a PowerShell module for creating and verifying JSON Web Tokens. This repository maintains the current `Jwt` module command surface under PSModule maintenance so existing users can continue to install and use the package from PowerShell Gallery.
`Jwt` is a PowerShell module for creating, parsing, validating, and inspecting [JSON Web Tokens (RFC 7519)](https://datatracker.ietf.org/doc/html/rfc7519) and the JOSE specs it builds on ([RFC 7515 — JWS](https://datatracker.ietf.org/doc/html/rfc7515), [RFC 7517 — JWK](https://datatracker.ietf.org/doc/html/rfc7517), [RFC 7518 — JWA](https://datatracker.ietf.org/doc/html/rfc7518), [RFC 7638 — JWK Thumbprint](https://datatracker.ietf.org/doc/html/rfc7638)). All cryptography uses the .NET BCL — no third-party dependencies.

> **Breaking change in v2.** The v1 surface (`New-Jwt -PayloadJson`, `Test-Jwt -Cert`, etc.) has been replaced with a typed object model. See [Migration from v1](#migration-from-v1).

## Installation

Expand All @@ -9,62 +11,182 @@ Install-PSResource -Name Jwt
Import-Module -Name Jwt
```

## Commands
Requires PowerShell 7.6 or newer. Windows PowerShell 5.1 is not supported.

## Algorithms

| Family | Algorithms | Key shapes |
| ------- | ------------------------------------------------------- | --------------------------------------------------------------------------- |
| HMAC | `HS256`, `HS384`, `HS512` | `byte[]`, raw secret string, `SecureString`, `JwtKey` (kty=oct) |
| RSA | `RS256`, `RS384`, `RS512` | `RSA`, RSA PEM string, `JwtKey` (kty=RSA) |
| RSA-PSS | `PS256`, `PS384`, `PS512` | `RSA`, RSA PEM string, `JwtKey` (kty=RSA) |
| ECDSA | `ES256` (P-256), `ES384` (P-384), `ES512` (P-521) | `ECDsa`, EC PEM string, `JwtKey` (kty=EC) |
| None | `none` | No key. Rejected by `Test-Jwt` unless `-AllowUnsigned` is supplied. |

The curve attached to an ECDSA key is checked against the algorithm's required curve before any signature work, and HMAC keys are rejected when supplied for an asymmetric algorithm — both block the classic [algorithm-confusion attack](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/).

## Public surface

The maintained module exports the same JWT commands and alias used by the current package:
| Function | Purpose |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `New-Jwt` | Create a JWT from header overrides and claims; sign locally or `-Unsigned` |
| `ConvertFrom-Jwt` | Parse a compact JWT string into a typed `[Jwt]` (no validation) |
| `Test-Jwt` | Verify the signature and registered claims (`exp`, `nbf`, `iss`, `aud`) |
| `Get-JwtHeader` | Return the parsed `[JwtHeader]` of a token |
| `Get-JwtPayload` | Return the parsed `[JwtPayload]` of a token |
| `Get-JwtClaim` | Return one or more named claims (registered or private) |
| `ConvertTo-JwtKey` | Convert an `RSA` / `ECDsa` / `byte[]` into a `[JwtKey]` (JWK) |
| `ConvertFrom-JwtKey` | Convert a `[JwtKey]` (JWK) back into a .NET key |
| `ConvertTo-JwtKeySet` | Wrap one or more `[JwtKey]` in a `[JwtKeySet]` (JWKS) |
| `ConvertFrom-JwtKeySet` | Parse a JWKS JSON document into a `[JwtKeySet]` |
| `Get-JwtKeyFromSet` | Look up a `[JwtKey]` in a `[JwtKeySet]` by `kid` |
| `Get-JwtKeyThumbprint` | Compute the RFC 7638 JWK thumbprint of a key (`SHA-256` / `SHA-384` / `SHA-512`) |
| `ConvertTo-Base64UrlString` / `ConvertFrom-Base64UrlString` | Base64url codec helpers (RFC 4648 §5) |

Public types: `[Jwt]`, `[JwtHeader]`, `[JwtPayload]`, `[JwtKey]`, `[JwtKeySet]`, `[JwtBase64Url]`.

## Create

### HS256 with a shared secret

```powershell
ConvertFrom-Base64UrlString
ConvertTo-Base64UrlString
Get-JwtHeader
Get-JwtPayload
New-Jwt
Test-Jwt
Verify-JwtSignature
$jwt = New-Jwt -Payload @{
sub = '1234567890'
name = 'John Doe'
admin = $true
iat = 1516239022
} -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long'

$jwt.ToString()
```

## Usage
### RS256 / PS256 with a local RSA key

```powershell
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
New-Jwt -Payload @{ sub = 'app'; iss = 'https://issuer'; exp = 1900000000 } `
-Header @{ kid = 'key-1' } -Algorithm RS256 -Key $rsa

# RSA-PSS variant
New-Jwt -Payload @{ sub = 'app' } -Algorithm PS256 -Key $rsa
```

Create and validate an HMAC-signed JWT:
### ES256 / ES384 / ES512 with an EC key

```powershell
$header = '{"alg":"HS256","typ":"JWT"}'
$payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}'
$secret = 'a-string-secret-at-least-256-bits-long'
$ec = [System.Security.Cryptography.ECDsa]::Create(
[System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7')) # P-256
New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -Key $ec
```

$jwt = New-Jwt -Header $header -PayloadJson $payload -Secret $secret
Test-Jwt -jwt $jwt -Secret $secret
### Unsigned token, sign externally (HSM / Azure Key Vault)

```powershell
$jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm RS256 -Unsigned
$jwt.SigningInput() # 'header.payload' — feed this to your external signer
$jwt.Signature = $externalSig # base64url signature returned by Key Vault / HSM
$jwt.ToString()
```

Read the header and payload from an existing token:
## Parse

```powershell
Get-JwtHeader -jwt $jwt
Get-JwtPayload -jwt $jwt
$parsed = ConvertFrom-Jwt -Token $compactString
$parsed.Header.alg
$parsed.Payload.sub
$parsed.Payload.AdditionalFields['groups']
```

For more information about each command, use PowerShell help:
## Inspect

```powershell
Get-Command -Module Jwt
Get-Help New-Jwt -Full
Get-JwtHeader -Token $compactString
Get-JwtPayload -Token $compactString
Get-JwtClaim -Token $compactString -Name 'sub'
Get-JwtClaim -Token $compactString -Name @('sub', 'role', 'missing') # ordered hashtable, $null for missing
```

`Get-JwtClaim` silently returns `$null` for a missing single claim; pass `-ErrorIfMissing` to escalate to non-terminating errors.

## Validate

```powershell
Test-Jwt -Token $compactString -Key $rsaPublic `
-Issuer 'https://issuer' -Audience 'api' -ClockSkew ([timespan]::FromMinutes(2))

# Structured report
Test-Jwt -Token $compactString -Key $rsaPublic -Detailed
```

`-Detailed` returns:

```text
Valid : True
SignatureValidated : True
Algorithm : RS256
Checks : @(
@{ Name = 'Algorithm'; Passed = $true; Reason = $null }
@{ Name = 'Signature'; Passed = $true; Reason = $null }
@{ Name = 'Expiration'; Passed = $true; Reason = $null }
@{ Name = 'NotBefore'; Passed = $true; Reason = $null }
@{ Name = 'Issuer'; Passed = $true; Reason = $null }
@{ Name = 'Audience'; Passed = $true; Reason = $null }
)
```

## Keys (JWK + JWKS + thumbprints)

```powershell
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
$jwk = ConvertTo-JwtKey -Key $rsa -KeyId 'key-1' -Algorithm 'RS256'
$jwk.ToJson()

$rsa2 = ConvertFrom-JwtKey -Key $jwk

# RFC 7638 thumbprint, suitable as a stable kid
Get-JwtKeyThumbprint -Key $jwk # SHA-256 (default)
Get-JwtKeyThumbprint -Key $jwk -HashAlgorithm SHA384

# JWK Set — publish or consume a JWKS endpoint
$set = $jwk1, $jwk2 | ConvertTo-JwtKeySet
$json = $set.ToJson() # publish

$set2 = ConvertFrom-JwtKeySet -Json (Invoke-RestMethod 'https://issuer/.well-known/jwks.json' | ConvertTo-Json -Depth 100)
$key = Get-JwtKeyFromSet -KeySet $set2 -KeyId (Get-JwtHeader $token).kid
Test-Jwt -Token $token -Key $key
```

Supported `kty`: `RSA`, `EC` (P-256 / P-384 / P-521), `oct` (HMAC).

## Roadmap

The v2 release covers the JWS half of JOSE end-to-end (RFC 7515 / 7517 / 7518 §3 / 7519 / 7638). The following are tracked as follow-ups:

- **JWE — RFC 7516 + RFC 7518 §4–§5.** `Protect-Jwt` / `Unprotect-Jwt` plus the full key-management and content-encryption matrix (`RSA-OAEP-256`, `A128/192/256KW`, `A128/192/256GCMKW`, `dir`, `ECDH-ES` family, `PBES2-*`, content algorithms `A128/192/256GCM`, `A128CBC-HS256` family). Not in scope for v2 because the surface is large and the AES-CBC-HMAC mode in particular requires careful constant-time MAC-then-decrypt to avoid padding-oracle bugs.
- **EdDSA — RFC 8037.** `Ed25519` and `Ed448` over the `OKP` key type. Blocked on first-party Ed25519 support landing in `System.Security.Cryptography`; the project's "no third-party dependencies" rule rules out a BouncyCastle workaround.
- **`RSA1_5` key wrap.** Spec-listed but Bleichenbacher-vulnerable. Will not be implemented; modern profiles use `RSA-OAEP-256`.

## Migration from v1

| v1 | v2 |
| ------------------------------------------------------- | ------------------------------------------------------------------------ |
| `New-Jwt -Header '{...}' -PayloadJson '{...}' -Secret` | `New-Jwt -Payload @{...} -Algorithm HS256 -Key $secret` |
| `New-Jwt -Cert $cert ...` | `$rsa = $cert.GetRSAPrivateKey(); New-Jwt -Key $rsa` |
| `Test-Jwt -Cert $cert ...` | `Test-Jwt -Key $rsa ...` (or `-Key $jwk`) |
| `Get-JwtHeader` / `Get-JwtPayload` returned strings | Now return typed `[JwtHeader]` / `[JwtPayload]` objects |
| `Verify-JwtSignature` alias | Removed — use `Test-Jwt` |

## Contributing

Coder or not, you can contribute to the project! We welcome all contributions.

### For Users

If you don't code, you still sit on valuable information that can make this project even better. If you experience that the
product does unexpected things, throw errors or is missing functionality, you can help by submitting bugs and feature requests.
product does unexpected things, throws errors, or is missing functionality, you can help by submitting bugs and feature requests.
Please see the issues tab on this project and submit a new issue that matches your needs.

### For Developers

If you do code, we'd love to have your contributions. Please read the [Contribution guidelines](CONTRIBUTING.md) for more information.
You can either help by picking up an existing issue or submit a new one if you have an idea for a new feature or improvement.

## Acknowledgements

Here is a list of people and projects that helped this project in some way.
47 changes: 47 additions & 0 deletions src/classes/public/Jwt.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
class Jwt {
[JwtHeader] $Header
[JwtPayload] $Payload
[string] $Signature
[string] $EncodedHeader
[string] $EncodedPayload

Jwt() {}

Jwt([JwtHeader] $header, [JwtPayload] $payload) {
$this.Header = $header
$this.Payload = $payload
$this.EncodedHeader = [JwtBase64Url]::EncodeString($header.ToJson())
$this.EncodedPayload = [JwtBase64Url]::EncodeString($payload.ToJson())
$this.Signature = ''
}

Jwt([JwtHeader] $header, [JwtPayload] $payload, [string] $signature) {
$this.Header = $header
$this.Payload = $payload
$this.EncodedHeader = [JwtBase64Url]::EncodeString($header.ToJson())
$this.EncodedPayload = [JwtBase64Url]::EncodeString($payload.ToJson())
$this.Signature = $signature
}

Jwt(
[JwtHeader] $header,
[JwtPayload] $payload,
[string] $signature,
[string] $encodedHeader,
[string] $encodedPayload
) {
$this.Header = $header
$this.Payload = $payload
$this.EncodedHeader = $encodedHeader
$this.EncodedPayload = $encodedPayload
$this.Signature = $signature
}

[string] SigningInput() {
return "$($this.EncodedHeader).$($this.EncodedPayload)"
}

[string] ToString() {
return "$($this.EncodedHeader).$($this.EncodedPayload).$($this.Signature)"
}
}
28 changes: 28 additions & 0 deletions src/classes/public/JwtBase64Url.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class JwtBase64Url {
static [string] Encode([byte[]] $bytes) {
if ($null -eq $bytes -or $bytes.Length -eq 0) { return '' }
$b64 = [Convert]::ToBase64String($bytes)
return $b64.TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

static [string] EncodeString([string] $value) {
if ($null -eq $value) { return '' }
return [JwtBase64Url]::Encode([System.Text.Encoding]::UTF8.GetBytes($value))
}

static [byte[]] Decode([string] $value) {
if ([string]::IsNullOrEmpty($value)) { return , [byte[]]::new(0) }
$s = $value.Replace('-', '+').Replace('_', '/')
switch ($s.Length % 4) {
2 { $s += '==' }
3 { $s += '=' }
0 {}
default { throw [System.FormatException]::new("Invalid base64url string length: $($value.Length).") }
}
return [Convert]::FromBase64String($s)
}

static [string] DecodeString([string] $value) {
return [System.Text.Encoding]::UTF8.GetString([JwtBase64Url]::Decode($value))
}
}
37 changes: 37 additions & 0 deletions src/classes/public/JwtHeader.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class JwtHeader {
[string] $alg
[string] $typ = 'JWT'
[string] $kid
[hashtable] $AdditionalFields = @{}

JwtHeader() {}

JwtHeader([System.Collections.IDictionary] $values) {
if ($null -eq $values) { return }
foreach ($key in $values.Keys) {
switch ($key) {
'alg' { $this.alg = [string]$values[$key] }
'typ' { $this.typ = [string]$values[$key] }
'kid' { $this.kid = [string]$values[$key] }
default { $this.AdditionalFields[$key] = $values[$key] }
}
}
}

[System.Collections.Specialized.OrderedDictionary] ToOrderedDictionary() {
$o = [ordered]@{}
if ($this.alg) { $o['alg'] = $this.alg }
if ($this.typ) { $o['typ'] = $this.typ }
if ($this.kid) { $o['kid'] = $this.kid }
if ($null -ne $this.AdditionalFields) {
foreach ($key in $this.AdditionalFields.Keys) {
$o[$key] = $this.AdditionalFields[$key]
}
}
return $o
}

[string] ToJson() {
return ConvertTo-Json -InputObject $this.ToOrderedDictionary() -Depth 100 -Compress
}
}
Loading
Loading