Package

jwt

JSON Web Tokens and JWS: HS256, HS384, HS512 and EdDSA, strict parsing, claim validation with leeway, typed keys bound to one algorithm, and JWKS

0.1.0 MIT

Install

sspur add jwt@^0.1.0

or in sspur.toml:

jwt = { version = "^0.1.0" }

README

jwt

JSON Web Tokens (RFC 7519) as compact JWS (RFC 7515), signed and verified with HS256, HS384, HS512 (RFC 7518) or EdDSA over Ed25519 (RFC 8037), with JWK and JWK set support (RFC 7517, RFC 7638 thumbprints). It follows the JWT best current practices of RFC 8725:

  • Keys are typed and bound to one algorithm. The alg in a token's header only chooses among the keys and the algorithms you allow; it is never trusted on its own, so an HMAC key can't verify an EdDSA token and a public key can't be used as an HMAC secret (algorithm confusion).
  • none (in any case) and unimplemented algorithms are refused, as are unknown crit extensions, unencoded payloads (b64: false), JWE, non-canonical base64url (padding, stray characters, nonzero trailing bits), headers and payloads that aren't JSON objects, duplicate member names, and tokens over a size limit (8192 characters by default).
  • HMAC keys must be at least as long as the hash (RFC 7518 section 3.2), checked when the key is made and again when it is used.
  • Claims (exp, nbf, iat, iss, aud, sub, jti, typ) are checked against a time you pass in, with a leeway, a maximum age, accepted issuers and audiences, a pinned subject and required claims. verify_now reads the clock.

Install

$ sspur add jwt

Example

type Access = {sub: Str, scope: Str}

fn main() -> Unit ! log, rand
= do
  issuer = jwt.with_kid(jwt.generate_ed25519_key(), "2026-10")
  now = 1760000000
  c = jwt.claims() with iss := some("https://auth.example.com"), sub := some("user-42"), aud := ["api"], iat := some(now), exp := some(now + 900)
  token = jwt.sign(issuer, c, [("scope", jwt.jstr("notes:read notes:write"))]).or("")
  log("token has {token.split(".").len} parts, header {jwt.decode_unverified(token, 8192).map(j => jwt.json_text(j.header))}")
  published = jwt.public_jwks([issuer])
  log(published.take(60) + "...")
  keys = jwt.parse_jwks(published).or([])
  p = jwt.policy([jwt.EdDSA]) with issuers := ["https://auth.example.com"], audiences := ["api"], leeway := 30
  match jwt.verify(token, keys, p, now + 60)
  | ok(t) => log("verified {t.claims.sub}, scopes {jwt.claim_strs(t, "scope")}, typed {json.decode[Access](t.text)}")
  | err(e) => log("refused: {e}")
  log("later: {jwt.verify(token, keys, p, now + 2000).map(t => t.claims.sub)}")
  log("other audience: {jwt.verify(token, keys, p with audiences := ["billing"], now).map(t => t.claims.sub)}")
  forged = "eyJhbGciOiJub25lIn0." + token.split(".")[1] + "."
  log("alg none: {jwt.verify(forged, keys, p, now).map(t => t.claims.sub)}")
  hs = jwt.hmac_key(jwt.HS384, secret(crypto.random(48)))
  log("HS384: {hs.map(k => jwt.sign(k, c, []).map(t => jwt.verify(t, [k], jwt.policy([jwt.HS384]), now).is_ok))}")
  log("short key: {jwt.hmac_key(jwt.HS256, secret("too short".to_bytes)).map(k => jwt.key_alg(k))}")
$ sspur run main.ssp
token has 3 parts, header ok("{\"alg\":\"EdDSA\",\"kid\":\"2026-10\",\"typ\":\"JWT\"}")
{"keys":[{"kty":"OKP","crv":"Ed25519","x":"4hSmEzGPbFSEvATPR...
verified some("user-42"), scopes some(["notes:read", "notes:write"]), typed ok(Access{sub: "user-42", scope: "notes:read notes:write"})
later: err(Expired{exp: 1760000900, now: 1760002000})
other audience: err(WrongAudience{aud: ["api"]})
alg none: err(UnsupportedAlg{alg: "none"})
HS384: ok(ok(true))
short key: err(InvalidKey{why: "an HS256 key must be at least 32 bytes"})

(sspur also prints one audit A_DECLASSIFY line for jwt's HMAC-SHA-384, the one place the package declassifies a value: the HMAC tag it computes from a secret key.)

In an http.serve handler: jwt.bearer_token(req) reads Authorization: Bearer, then jwt.verify_now(token, keys, policy). Decode your own claims with json.decode[MyClaims](t.text).

API

Keys

DefinitionWhat it is
AlgHS256, HS384, HS512, EdDSA
alg_name(a), alg_of(name), all_algs()Header names (case-sensitive; none is not an algorithm)
KeyHmacKey, EdSigningKey or EdVerifyingKey, each bound to one algorithm, with an optional kid; derives Eq (structural, for tests) and Show (secrets print as <secret>)
KeyBytesThe secret material inside a key (new Secret[Bytes]; constant-time ==, shows as <secret>)
hmac_key(alg, secret)Checks the length: 32, 48 or 64 bytes at least
ed25519_signing_key(seed), ed25519_verifying_key(x)From the 32-byte seed (JWK d) or public key (JWK x)
generate_hmac_key(alg), generate_ed25519_key()Random keys (! rand)
with_kid(k, kid), key_id(k), key_alg(k), can_sign(k)Key ids and properties
verifying_key(k), public_bytes(k)The shareable half of an Ed25519 key (none for HMAC)
select_keys(keys, kid, alg)The keys a token with that alg and kid may be verified with

Verifying

DefinitionWhat it is
Policy, policy(algs)Allowed algorithms, leeway, max_age, issuers, audiences, subject, typ, required claims, understood critical extensions, max_len
verify(token, keys, p, now)Signature, then payload, then claims; gives a Token
verify_now(token, keys, p)The same at the current time (! time)
check_claims(t, p, now)The claim checks alone, for a decoded token
Tokenheader, claims, payload (parsed), text (the payload as signed), alg, kid, typ
Claims, claims()The registered claims; aud is always a list
claim(t, n), claim_str, claim_int, claim_bool, claim_strsCustom claims; claim_strs reads a string or an array, and splits scope on spaces
claims_of(json)The registered claims of a payload, type-checked
decode_jwt_unverified(token, max_len)Decodes without checking anything; never trust it
bearer_token(req)The token of Authorization: Bearer (RFC 6750)

Signing

DefinitionWhat it is
sign(k, c, extra)A JWT with header alg, typ: JWT and kid; payload c plus your claims
sign_payload(k, json)A JWT whose payload is your own JSON text, such as json.encode(MyClaims{...})
claims_json(c, extra)The payload sign would write

JWS (any payload)

DefinitionWhat it is
jws_sign(k, extra_headers, payload)A compact JWS of any text
jws_verify(token, keys, p)Verifies a compact JWS with the policy's algs, critical and max_len
sign_compact(k, header, payload)Signs with exact header text, for reproducing tokens byte for byte
decode_unverified(token, max_len)The header and payload, unchecked
Jwsheader, payload, alg, kid
b64url(b), from_b64url(s)Unpadded base64url; decoding accepts only the canonical form

JWK

DefinitionWhat it is
parse_jwks(text)The verifying keys of a public set; skips RSA, EC, X25519 and keys not for signatures; refuses sets with private material
parse_private_jwks(text)Signing keys too: Ed25519 with d (checked against x) and oct keys with their alg
parse_jwk(text)One key, private parts allowed
public_jwk(k), public_jwks(keys)Publish your verifying keys
thumbprint(k)RFC 7638 thumbprint of an Ed25519 key

Supporting functions

DefinitionWhat it is
JwtErrEvery reason a token, key or set was refused: Malformed, TooLarge, UnsupportedAlg, AlgNotAllowed, UnknownCritical, NoKey, BadSignature, Expired, NotYetValid, IssuedInFuture, IssuedTooLongAgo, WrongIssuer, WrongAudience, WrongSubject, WrongType, MissingClaim, BadClaim, InvalidKey, BadJwks
JsonValue, parse_json(s), json_text(v), member(v, n), json_int(v), jint(n), jstr(s)The strict JSON reader the tokens are parsed with (one value, no duplicate members, no lone surrogates, 32 levels) and its writer
sha384(data), hmac_sha384(key, data)SHA-384 and HMAC-SHA-384, which HS384 needs and the standard library lacks

Security notes

  • Always pass the algorithms you expect to policy. The header's alg must be in that list and match the key's.
  • With a kid in the header only keys with that id are tried; without one, every key of the token's algorithm is.
  • exp must be strictly after now - leeway; nbf at most now + leeway; an iat in the future (beyond the leeway) is refused.
  • jku, x5u and jwk headers are never followed: keys come only from you.
  • Test vectors: RFC 7515 A.1 (the exact HS256 token, also RFC 7519 section 3.1), RFC 8037 A.1, A.3 and A.4 (the Ed25519 key, its thumbprint and the exact EdDSA token), FIPS 180-4 SHA-384, RFC 4231 HMAC-SHA-256, -384 and -512 cases. The SHA-384 code is the SHA-512 algorithm, checked against crypto.sha512 on inputs across every padding boundary, and its constants are recomputed in the tests from the cube and square roots of the primes.

Out of scope

  • RS256/384/512, PS256/384/512 and ES256/384/512: the standard library has no RSA or ECDSA, and this package doesn't implement public-key cryptography of its own. Tokens with these algorithms are refused as UnsupportedAlg, and RSA and EC keys in a JWK set are skipped.
  • JWE (encrypted tokens), unencoded payloads (RFC 7797), and the JSON (non-compact) serializations.
  • Fetching JWK sets over HTTP and caching them: fetch with http.fetch, then parse_jwks.

License

MIT. See LICENSE.

API

JwtErr type

pub type JwtErr = Malformed{why: Str} | TooLarge{len: Int, max: Int} | UnsupportedAlg{alg: Str} | AlgNotAllowed{alg: Str} | UnknownCritical{name: Str} | NoKey{kid: Opt[Str]} | BadSignature | Expired{exp: Int, now: Int} | NotYetValid{nbf: Int, now: Int} | IssuedInFuture{iat: Int, now: Int} | IssuedTooLongAgo{iat: Int, max_age: Int} | WrongIssuer{iss: Opt[Str]} | WrongAudience{aud: List[Str]} | WrongSubject{sub: Opt[Str]} | WrongType{typ: Opt[Str]} | MissingClaim{name: Str} | BadClaim{name: Str, why: Str} | InvalidKey{why: Str} | BadJwks{why: Str} derive Eq, Show

Why a token, a key or a key set was refused.

sha384 fn

fn sha384(data: Bytes) -> Bytes

The SHA-384 digest (48 bytes) of data, FIPS 180-4.

hmac_sha384 fn

fn hmac_sha384(key: Secret[Bytes], data: Bytes) -> Bytes

HMAC-SHA-384 (RFC 2104, RFC 4231) of data under key: 48 bytes.

JsonValue type

pub type JsonValue = JNull | JBool{value: Bool} | JNum{text: Str} | JStr{value: Str} | JArr{items: List[JsonValue]} | JObj{fields: List[(Str, JsonValue)]} derive Eq, Show

A JSON value as parsed from a token, keeping the members of an object in their order and numbers as their text (so nothing is lost or rounded).

parse_json fn

fn parse_json(s: Str) -> Res[JsonValue, Str]

Parses JSON text (RFC 8259) strictly: one value, nested at most 32 deep, no duplicate member names in an object, no lone surrogates, nothing after the value. err says what is wrong.

json_text fn

fn json_text(v: JsonValue) -> Str

The compact JSON text of v: no spaces, members in order, strings escaped as RFC 8259 requires (quote, backslash and control characters), other characters as they are.

jint fn

fn jint(n: Int) -> JsonValue

A JSON number for an integer.

jstr fn

fn jstr(s: Str) -> JsonValue

A JSON string.

member fn

fn member(v: JsonValue, name: Str) -> Opt[JsonValue]

The member name of an object, or none (also for values that are not objects).

json_int fn

fn json_int(v: JsonValue) -> Opt[Int]

The integer a JSON number denotes, rounded down when it has a fraction or an exponent (NumericDate values may be fractional), or none when it doesn't fit in an Int.

b64url fn

fn b64url(b: Bytes) -> Str

b as unpadded base64url, the encoding of every JWS segment.

from_b64url fn

fn from_b64url(s: Str) -> Opt[Bytes]

The bytes of a canonical unpadded base64url segment: only A-Z a-z 0-9 - _, no padding, no whitespace, and unused trailing bits zero. Anything else is none.

Alg type

pub type Alg = HS256 | HS384 | HS512 | EdDSA derive Eq, Show

A JWS algorithm this package implements: HMAC with SHA-256, SHA-384 or SHA-512, and EdDSA with Ed25519.

alg_name fn

fn alg_name(a: Alg) -> Str

The alg header value of a.

alg_of fn

fn alg_of(name: Str) -> Opt[Alg]

The algorithm an alg header value names, matched exactly (names are case-sensitive), or none. none and every algorithm this package doesn't implement are none.

all_algs fn

fn all_algs() -> List[Alg]

Every algorithm, for a policy that accepts whatever the keys allow.

Key type

pub type Key = HmacKey{alg: Alg, kid: Opt[Str], mac_key: KeyBytes} | EdSigningKey{kid: Opt[Str], seed: KeyBytes, x: Bytes} | EdVerifyingKey{kid: Opt[Str], x: Bytes} derive Eq, Show

A key, bound to exactly one algorithm: an HMAC secret for alg, an Ed25519 signing key (its 32-byte secret seed and public key x), or an Ed25519 verifying key. kid is the key id that tokens name in their header. Keys hold secrets, which can't be compared or printed, so Key has hand-written Eq (secrets compared in constant time) and Show (secrets shown as <secret>) instead of derived ones. Build keys with hmac_key, ed25519_signing_key and ed25519_verifying_key, which check them.

KeyBytes type

pub type KeyBytes = new Secret[Bytes]

Secret key material, so keys can derive Eq and Show: it prints as <secret> (a Key shows it as KeyBytes(<secret>)), and two KeyBytes compare in constant time. Unwrap with .raw.

hmac_key fn

fn hmac_key(alg: Alg, k: Secret[Bytes]) -> Res[Key, JwtErr]

An HMAC key for alg (HS256, HS384 or HS512). RFC 7518 section 3.2 requires a key at least as long as the hash: 32, 48 or 64 bytes.

ed25519_signing_key fn

fn ed25519_signing_key(seed: Secret[Bytes]) -> Res[Key, JwtErr]

An Ed25519 signing key from its 32-byte secret seed (the JWK d).

ed25519_verifying_key fn

fn ed25519_verifying_key(x: Bytes) -> Res[Key, JwtErr]

An Ed25519 verifying key from its 32-byte public key (the JWK x).

generate_hmac_key fn

fn generate_hmac_key(alg: Alg) -> Res[Key, JwtErr] ! rand

A new random HMAC key of the minimum size for alg.

with_kid fn

fn with_kid(k: Key, kid: Str where _.len > 0) -> Key

k with the key id kid.

key_alg fn

fn key_alg(k: Key) -> Alg

The algorithm k is bound to.

key_id fn

fn key_id(k: Key) -> Opt[Str]

The key id of k, if it has one.

can_sign fn

fn can_sign(k: Key) -> Bool

Whether k can sign: HMAC keys and Ed25519 signing keys can, verifying keys can't.

verifying_key fn

fn verifying_key(k: Key) -> Opt[Key]

The key that verifies what k signs and may be given to others: the verifying key of an Ed25519 signing key, k itself for a verifying key, and none for an HMAC key, whose verifier is the secret itself.

public_bytes fn

fn public_bytes(k: Key) -> Opt[Bytes]

The public key bytes of an Ed25519 key, none for an HMAC key.

select_keys fn

fn select_keys(keys: List[Key], kid: Opt[Str], alg: Alg) -> List[Key]

The keys of keys that may verify a token with algorithm alg and key id kid: bound to alg, and when kid is given, carrying that id.

Policy type

pub type Policy = {algs: List[Alg], leeway: Int where _ >= 0, max_age: Opt[Int], issuers: List[Str], audiences: List[Str], subject: Opt[Str], typ: Opt[Str], required: List[Str], critical: List[Str], max_len: Int where _ > 0} derive Eq, Show

What verification accepts. algs is the allowlist (the header's alg must be in it and match the key's); leeway is the clock skew allowed, in seconds, for exp, nbf and iat; max_age limits how old iat may be; issuers and audiences, when not empty, are the accepted iss values and the audiences of which aud must contain one; subject and typ pin sub and the header's typ; required lists claims that must be present; critical lists the crit header extensions the caller understands; max_len bounds the token length.

policy fn

fn policy(algs: List[Alg] where _.len > 0) -> Policy

A policy that accepts algs and checks only the time claims that are present, with no leeway and tokens of at most 8192 characters. Add requirements with with.

Jws type

pub type Jws = {header: JsonValue, payload: Str, alg: Alg, kid: Opt[Str]} derive Eq, Show

A verified (or, from decode_unverified, merely decoded) JWS: its protected header, its payload as text, and the algorithm and key id it named.

jws_verify fn

fn jws_verify(token: Str, keys: List[Key], p: Policy) -> Res[Jws, JwtErr]

Verifies a compact JWS with keys under policy p's algs, critical and max_len. The header's alg must be in p.algs; then only keys bound to that algorithm, with the header's kid when it has one, are tried. The payload can be any text; verify adds the JWT rules.

sign_compact fn

fn sign_compact(k: Key, header: Str, payload: Str) -> Res[Str, JwtErr]

Signs payload with the exact header text header, which must be a JSON object whose alg is k's algorithm. For reproducing tokens byte for byte; jws_sign and sign build the header themselves.

jws_sign fn

fn jws_sign(k: Key, extra: List[(Str, JsonValue)], payload: Str) -> Res[Str, JwtErr]

Signs payload (any text) as a compact JWS: the header is alg from the key, kid when the key has one, then extra (which may not set alg, kid, crit or b64).

decode_unverified fn

fn decode_unverified(token: Str, max_len: Int where _ > 0) -> Res[Jws, JwtErr]

Decodes a token without checking its signature, for reading its header (kid, alg) before choosing keys, or for debugging. Never trust what it returns. Parsing is as strict as jws_verify's, with no crit extension understood.

Claims type

pub type Claims = {iss: Opt[Str], sub: Opt[Str], aud: List[Str], exp: Opt[Int], nbf: Opt[Int], iat: Opt[Int], jti: Opt[Str]} derive Eq, Show

The registered claims of RFC 7519 section 4.1. aud is a list whether the token carries one string or an array; the times are NumericDates in Unix seconds.

claims fn

fn claims() -> Claims

No claims, to fill in with with.

Token type

pub type Token = {header: JsonValue, claims: Claims, payload: JsonValue, text: Str, alg: Alg, kid: Opt[Str], typ: Opt[Str]} derive Eq, Show

A verified JWT: its JWS header, the registered claims, the whole payload as parsed JSON and as text (decode it into your own type with json.decode[T](t.payload)), and the header's alg, kid and typ.

claims_of fn

fn claims_of(payload: JsonValue) -> Res[Claims, JwtErr]

The registered claims of a JWT payload, which must be a JSON object, with each registered claim of the right type.

claims_json fn

fn claims_json(c: Claims, extra: List[(Str, JsonValue)]) -> Res[JsonValue, JwtErr]

The JSON object of c's claims that are set, then extra (your own claims). A name in extra that is also a registered claim set in c, or that appears twice, is an error.

check_claims fn

fn check_claims(t: Token, p: Policy, now: Int) -> Res[Unit, JwtErr]

Checks the claims and the typ of a decoded token against p at now (Unix seconds): exp must be after now - leeway, nbf at most now + leeway, iat not after now + leeway (and within max_age when set), iss, aud, sub and typ as p requires, and every claim in p.required present. typ compares without case and without an application/ prefix (RFC 7515 section 4.1.9).

verify fn

fn verify(token: Str, keys: List[Key], p: Policy, now: Int) -> Res[Token, JwtErr]

Verifies a JWT: the signature as jws_verify does, then the payload as a JSON object with well-typed registered claims, then check_claims at now (Unix seconds).

verify_now fn

fn verify_now(token: Str, keys: List[Key], p: Policy) -> Res[Token, JwtErr] ! time

verify at the current time.

decode_jwt_unverified fn

fn decode_jwt_unverified(token: Str, max_len: Int where _ > 0) -> Res[Token, JwtErr]

Decodes a JWT without checking its signature or its claims. Never trust what it returns.

sign fn

fn sign(k: Key, c: Claims, extra: List[(Str, JsonValue)]) -> Res[Str, JwtErr]

Signs a JWT with k: header alg, typ: JWT and kid when the key has one; payload the claims of c that are set, then extra.

sign_payload fn

fn sign_payload(k: Key, payload: Str) -> Res[Str, JwtErr]

Signs the JSON object text payload as a JWT, for claims written with json.encode of your own record type. The text is signed exactly as given.

claim fn

fn claim(t: Token, name: Str) -> Opt[JsonValue]

The claim name of a token's payload, if present.

claim_str fn

fn claim_str(t: Token, name: Str) -> Opt[Str]

The claim name if it is a string.

claim_int fn

fn claim_int(t: Token, name: Str) -> Opt[Int]

The claim name if it is a number that fits an Int (rounded down).

claim_bool fn

fn claim_bool(t: Token, name: Str) -> Opt[Bool]

The claim name if it is a boolean.

claim_strs fn

fn claim_strs(t: Token, name: Str) -> Opt[List[Str]]

The claim name as a list of strings: a string is a list of one, an array must hold only strings. Scopes and roles are often one of these.

bearer_token fn

fn bearer_token(req: Request) -> Opt[Str]

The token of an Authorization: Bearer <token> header (RFC 6750), if the request has one.

parse_jwks fn

fn parse_jwks(text: Str) -> Res[List[Key], JwtErr]

The verifying keys of a public JWK set (RFC 7517 section 5), such as an issuer's jwks_uri document. Ed25519 keys (kty OKP, crv Ed25519) are read; keys of other types and curves (RSA, EC, X25519) and keys whose use or key_ops exclude verification are skipped. A set that carries private material (d, or a symmetric oct key) is refused, since publishing it would have leaked it.

parse_private_jwks fn

fn parse_private_jwks(text: Str) -> Res[List[Key], JwtErr]

The keys of a private JWK set: Ed25519 signing keys (with d, checked against x), verifying keys, and symmetric oct keys, which must name their alg so they are bound to it.

parse_jwk fn

fn parse_jwk(text: Str) -> Res[Opt[Key], JwtErr]

One JWK, private parts allowed; none when it is of a type this package skips.

public_jwk fn

fn public_jwk(k: Key) -> Opt[JsonValue]

The public JWK of k (kty OKP, crv Ed25519, x, with kid, alg and use sig), or none for an HMAC key, which has no public part.

public_jwks fn

fn public_jwks(keys: List[Key]) -> Str

The public JWK set of keys, as an issuer publishes it; HMAC keys are left out.

thumbprint fn

fn thumbprint(k: Key) -> Opt[Str]

The RFC 7638 thumbprint of an Ed25519 key (base64url SHA-256 of its canonical JWK, as RFC 8037 section A.3 shows), a common kid; none for an HMAC key.

Versions

VersionPublishedCommitSourceEffects
0.1.0 2026-10-161018450bd8snapshot