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
| Definition | What it is |
|---|
Alg | HS256, HS384, HS512, EdDSA |
alg_name(a), alg_of(name), all_algs() | Header names (case-sensitive; none is not an algorithm) |
Key | HmacKey, EdSigningKey or EdVerifyingKey, each bound to one algorithm, with an optional kid; derives Eq (structural, for tests) and Show (secrets print as <secret>) |
KeyBytes | The 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
| Definition | What 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 |
Token | header, 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_strs | Custom 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
| Definition | What 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)
| Definition | What 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 |
Jws | header, payload, alg, kid |
b64url(b), from_b64url(s) | Unpadded base64url; decoding accepts only the canonical form |
JWK
| Definition | What 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
| Definition | What it is |
|---|
JwtErr | Every 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
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.
fn sha384(data: Bytes) -> Bytes
The SHA-384 digest (48 bytes) of data, FIPS 180-4.
fn hmac_sha384(key: Secret[Bytes], data: Bytes) -> Bytes
HMAC-SHA-384 (RFC 2104, RFC 4231) of data under key: 48 bytes.
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).
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.
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.
fn jint(n: Int) -> JsonValue
A JSON number for an integer.
fn jstr(s: Str) -> JsonValue
fn member(v: JsonValue, name: Str) -> Opt[JsonValue]
The member name of an object, or none (also for values that are not objects).
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.
fn b64url(b: Bytes) -> Str
b as unpadded base64url, the encoding of every JWS segment.
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.
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.
fn alg_name(a: Alg) -> Str
The alg header value of a.
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.
fn all_algs() -> List[Alg]
Every algorithm, for a policy that accepts whatever the keys allow.
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.
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.
impl Eq for KeyBytes
Equal when the bytes are, compared in constant time.
impl Show for KeyBytes
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.
fn ed25519_signing_key(seed: Secret[Bytes]) -> Res[Key, JwtErr]
An Ed25519 signing key from its 32-byte secret seed (the JWK d).
fn ed25519_verifying_key(x: Bytes) -> Res[Key, JwtErr]
An Ed25519 verifying key from its 32-byte public key (the JWK x).
fn generate_hmac_key(alg: Alg) -> Res[Key, JwtErr] ! rand
A new random HMAC key of the minimum size for alg.
fn generate_ed25519_key() -> Key ! rand
A new random Ed25519 signing key.
fn with_kid(k: Key, kid: Str where _.len > 0) -> Key
fn key_alg(k: Key) -> Alg
The algorithm k is bound to.
fn key_id(k: Key) -> Opt[Str]
The key id of k, if it has one.
fn can_sign(k: Key) -> Bool
Whether k can sign: HMAC keys and Ed25519 signing keys can, verifying keys can't.
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.
fn public_bytes(k: Key) -> Opt[Bytes]
The public key bytes of an Ed25519 key, none for an HMAC key.
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.
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.
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.
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.
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.
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.
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).
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.
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.
fn claims() -> Claims
No claims, to fill in with with.
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.
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.
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.
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).
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).
fn verify_now(token: Str, keys: List[Key], p: Policy) -> Res[Token, JwtErr] ! time
verify at the current time.
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.
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.
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.
fn claim(t: Token, name: Str) -> Opt[JsonValue]
The claim name of a token's payload, if present.
fn claim_str(t: Token, name: Str) -> Opt[Str]
The claim name if it is a string.
fn claim_int(t: Token, name: Str) -> Opt[Int]
The claim name if it is a number that fits an Int (rounded down).
fn claim_bool(t: Token, name: Str) -> Opt[Bool]
The claim name if it is a boolean.
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.
fn bearer_token(req: Request) -> Opt[Str]
The token of an Authorization: Bearer <token> header (RFC 6750), if the request has one.
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.
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.
fn parse_jwk(text: Str) -> Res[Opt[Key], JwtErr]
One JWK, private parts allowed; none when it is of a type this package skips.
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.
fn public_jwks(keys: List[Key]) -> Str
The public JWK set of keys, as an issuer publishes it; HMAC keys are left out.
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.