Skip to content

std/jwt ​

JSON Web Tokens (RFC 7519 / 7515) over HMAC-SHA2, pure Milo on std/hmac, std/base64, std/json and std/subtle. HS256/384/512 are deterministic, so a token produced here is byte-identical to PyJWT / jsonwebtoken for the same header, payload, and secret.

milo
from "std/jwt" import { Jwt, JwtAlg, JwtVerifier, JwtError, jwtErrorMessage }

Verification returns the claims, not a bool. A yes/no verifier invites if verify(token, secret) { ... }, which happily accepts a token that expired three years ago or was minted for a different audience — the signature is only half the check. Result<JwtClaims, JwtError> makes "the signature is good" and "this token is valid right now" the same line of code.

Three attacks are handled explicitly:

  • alg: none — the header algorithm must equal the one the caller asked for, so an unsigned token is UnsupportedAlg("none").
  • Algorithm confusion — the algorithm comes from the verifier, never from the token.
  • Signature timing — the MAC comparison is constantTimeEq over raw bytes, and a non-canonical base64url signature is rejected rather than silently accepted.

HMAC algorithms only. RS*/ES*/PS* need public-key crypto the standard library does not have; such a token is rejected as unsupported rather than downgraded.

Signing and verifying ​

Jwt.signHS256 signs a JSON payload string and returns a compact JWS: base64url(header).base64url(payload).base64url(HMAC). Jwt.verifyHS256 checks the algorithm, the signature, and exp/nbf/iat against the system clock with JWT_DEFAULT_LEEWAY (60 s) of tolerance, then returns the claims.

milo
match Jwt.verifyHS256(token, secret) {
    Ok(claims) => {
        match claims.subject() {
            Some(sub) => { print("hello " + sub) }
            None => { print("anonymous token") }
        }
    }
    Err(e) => { print(jwtErrorMessage(e)) }
}

One policy, reused ​

JwtVerifier holds the policy in one place: which algorithm, how much clock skew, which audience and issuer, and whether a token without exp is acceptable. at pins the clock to a fixed instant, which is what makes a claim test reproducible.

milo
let verifier = JwtVerifier.new(JwtAlg.HS256, secret)
    .audience("api.example")
    .issuer("auth.example")
    .requireExpiry()
let claims = verifier.verify(token)?

A rejection is a JwtError variant, not a false: the caller recovers differently from Expired than from BadSignature. jwtErrorMessage(err) renders any of them as one log-safe line.

API reference ​

Jwt ​

milo
pub struct Jwt

Jwt namespace — Jwt.signHS256(payload, secret) and Jwt.verifyHS256(token, secret) for the common case; JwtVerifier when you need audience, issuer, or a fixed clock.

Jwt.sign ​

milo
fn Jwt.sign(alg: JwtAlg, payload: &string, secret: &string): string

Sign a JSON payload string with alg, returning a compact JWS.

Jwt.signHS256 ​

milo
fn Jwt.signHS256(payload: &string, secret: &string): string

Sign with HMAC-SHA256 — the JWT default.

Jwt.signHS384 ​

milo
fn Jwt.signHS384(payload: &string, secret: &string): string

Sign with HMAC-SHA384.

Jwt.signHS512 ​

milo
fn Jwt.signHS512(payload: &string, secret: &string): string

Sign with HMAC-SHA512.

Jwt.verifyHS256 ​

milo
fn Jwt.verifyHS256(token: &string, secret: &string): Result<JwtClaims, JwtError>

Verify an HS256 token against secret and return its claims. Rejects any other algorithm (including none), a bad signature, and a token that is expired or not yet valid, allowing JWT_DEFAULT_LEEWAY of clock skew. Use JwtVerifier to require an audience or issuer, or to fix the clock.

Jwt.verifyHS384 ​

milo
fn Jwt.verifyHS384(token: &string, secret: &string): Result<JwtClaims, JwtError>

Verify an HS384 token. Same checks as verifyHS256.

Jwt.verifyHS512 ​

milo
fn Jwt.verifyHS512(token: &string, secret: &string): Result<JwtClaims, JwtError>

Verify an HS512 token. Same checks as verifyHS256.

JwtAlg ​

milo
pub enum JwtAlg

The JOSE alg values this module can verify. Named, not stringly-typed, so a caller cannot accidentally ask for "none".

Variants:

  • HS256
  • HS384
  • HS512

JwtClaims ​

milo
pub struct JwtClaims

The verified payload of a token. Only ever produced by a successful verify, so holding one is the proof that the signature and the time window checked out.

Fields: payload: string, doc: Json.

JwtClaims.bool ​

milo
fn JwtClaims.bool(self: &JwtClaims, key: &string): Option<bool>

A boolean-valued claim, or None if absent or not a boolean.

JwtClaims.expiresAt ​

milo
fn JwtClaims.expiresAt(self: &JwtClaims): Option<i64>

exp — expiry, seconds since the Unix epoch (§4.1.4).

JwtClaims.hasAudience ​

milo
fn JwtClaims.hasAudience(self: &JwtClaims, want: &string): bool

True iff the aud claim is want, or is an array containing it. RFC 7519 §4.1.3 allows either shape, and a verifier that only understands the string form silently accepts every multi-audience token.

JwtClaims.i64 ​

milo
fn JwtClaims.i64(self: &JwtClaims, key: &string): Option<i64>

An integer-valued claim, or None if absent or not a number.

JwtClaims.issuedAt ​

milo
fn JwtClaims.issuedAt(self: &JwtClaims): Option<i64>

iat — issued-at, seconds since the Unix epoch (§4.1.6).

JwtClaims.issuer ​

milo
fn JwtClaims.issuer(self: &JwtClaims): Option<string>

iss — who minted the token (§4.1.1).

JwtClaims.notBefore ​

milo
fn JwtClaims.notBefore(self: &JwtClaims): Option<i64>

nbf — not-before, seconds since the Unix epoch (§4.1.5).

JwtClaims.raw ​

milo
fn JwtClaims.raw(self: &JwtClaims): string

The payload segment as its raw JSON text.

JwtClaims.str ​

milo
fn JwtClaims.str(self: &JwtClaims, key: &string): Option<string>

A string-valued claim, or None if absent or not a string.

JwtClaims.subject ​

milo
fn JwtClaims.subject(self: &JwtClaims): Option<string>

sub — the subject the token is about (RFC 7519 §4.1.2).

JwtClaims.tokenId ​

milo
fn JwtClaims.tokenId(self: &JwtClaims): Option<string>

jti — the token's unique id, for replay tracking (§4.1.7).

JwtError ​

milo
pub enum JwtError

Why a token was rejected. Callers genuinely recover differently: Expired means "refresh and retry", BadSignature means "this is an attack or the wrong key", WrongAudience means "this token is for another service" — collapsing them into false throws away the only information the caller needs.

Variants:

  • Malformed(string): Not three base64url segments, or a segment is not valid base64url / JSON.
  • UnsupportedAlg(string): The header's alg is not the one the verifier requires. Carries the value found in the token, so "none" shows up in the log verbatim.
  • UnsupportedCritical: The header carries a crit extension this implementation does not understand (RFC 7515 §4.1.11 requires rejection rather than ignoring it).
  • BadSignature: The MAC did not match. No detail: there is nothing safe to report.
  • Expired(i64): exp has passed. Carries the expiry, in seconds since the Unix epoch.
  • NotYetValid(i64): nbf is in the future. Carries the not-before time.
  • IssuedInFuture(i64): iat is in the future — the issuer's clock is wrong or the token is forged.
  • MissingClaim(string): A required claim (exp, or the aud/iss the verifier demands) is absent.
  • WrongAudience(string): aud is present but does not contain the audience the verifier requires.
  • WrongIssuer(string): iss is present but is not the issuer the verifier requires.

JwtVerifier ​

milo
pub struct JwtVerifier

A configured verifier: the algorithm and secret to require, plus which claims to insist on. Build one with JwtVerifier.new, chain the options you need, and call verify. Reusing a verifier is the point — the policy lives in one place instead of being re-spelled at every call site.

Fields:

  • secret: string
  • alg: JwtAlg
  • leewaySecs: i64
  • nowSecs: i64: Seconds since the epoch, or -1 to read the system clock at verify time. A fixed value makes claim validation deterministic in tests.
  • audience: string: "" means "do not check". An empty audience or issuer is not meaningful, so there is no ambiguity between "unset" and "must equal the empty string".
  • issuer: string
  • requireExp: bool

JwtVerifier.at ​

milo
fn JwtVerifier.at(self: JwtVerifier, unixSecs: i64): JwtVerifier

Evaluate the time claims against a fixed instant instead of the system clock. Seconds since the Unix epoch. This is what makes a claim test reproducible.

JwtVerifier.audience ​

milo
fn JwtVerifier.audience(self: JwtVerifier, want: string): JwtVerifier

Require an aud claim naming want (string or array form).

JwtVerifier.issuer ​

milo
fn JwtVerifier.issuer(self: JwtVerifier, want: string): JwtVerifier

Require an iss claim equal to want.

JwtVerifier.leeway ​

milo
fn JwtVerifier.leeway(self: JwtVerifier, secs: i64): JwtVerifier

Tolerate secs of clock skew in both directions on exp/nbf/iat.

JwtVerifier.new ​

milo
fn JwtVerifier.new(alg: JwtAlg, secret: &string): JwtVerifier

A verifier that requires alg and secret, allows JWT_DEFAULT_LEEWAY of clock skew, and validates whatever of exp/nbf/iat the token carries.

The secret is borrowed and copied in: a verifier is usually built per request from one long-lived secret, and taking ownership would move that secret out of the caller's variable on first use.

JwtVerifier.requireExpiry ​

milo
fn JwtVerifier.requireExpiry(self: JwtVerifier): JwtVerifier

Reject a token that carries no exp at all. Off by default because the claim is optional in RFC 7519; turn it on for anything session-shaped, where a token that never expires is a permanent credential.

JwtVerifier.verify ​

milo
fn JwtVerifier.verify(self: &JwtVerifier, token: &string): Result<JwtClaims, JwtError>

Check the signature and the claims, and hand back the payload.

Functions ​

jwtAlgName ​

milo
pub fn jwtAlgName(alg: &JwtAlg): string

The JOSE registry name, as it appears in the header.

jwtErrorMessage ​

milo
pub fn jwtErrorMessage(err: &JwtError): string

One-line, log-safe description of a rejection.