Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Standard library

Standard library

Signed tokens

On this page

crypto.verify_jwt checks a JSON Web Token (JWT) that another service signed, such as the token a partner's sign-in service gives a person. You give it the service's public keys, and it gives the token's claims once the signature, the times, the issuer and the audience all hold. It checks a token as RFC 8725 advises.

A clinic lets members sign in through a partner's sign-in service, which signs each token with Ed25519 and publishes its public keys as a key set (RFC 7517). The clinic reads the keys as Jwks and checks each token against them. The algorithm is the key's, never the token's, and the token's times, issuer and audience must hold. verify_jwt reads the clock, so it is called in an action:

example
type PartnerKeySet = Object {    keys: List<Jwk>}type KeySetText = Text {    max_length: 100000}type KeySetUnreadable = Failure {    kind: FailureKind.invalid_value}type PartnerTokenRefused = Failure {    kind: FailureKind.invalid_value}type PartnerTokenExpired = Failure {    kind: FailureKind.invalid_value}/// The partner's public keys, from the key set it publishes (RFC 7517).function partner_keys(published_key_set: KeySetText) fails KeySetUnreadable -> List<Jwk> {    var partner_key_set: PartnerKeySet = attempt json.read(published_key_set, as: PartnerKeySet) {        malformed: KeySetUnreadable        mismatch: KeySetUnreadable    }    return partner_key_set.keys}/// What a token from the partner's sign-in service says, once its/// signature, its times, its issuer and its audience hold.action partner_sign_in(    partner_token: Jwt,    partner_signing_keys: List<Jwk>) fails PartnerTokenRefused, PartnerTokenExpired -> JwtClaims {    return attempt crypto.verify_jwt(        partner_token,        keys: partner_signing_keys,        issuer: "https://id.example.com",        audience: "clinic"    ) {        invalid_signature: PartnerTokenRefused        expired: PartnerTokenExpired        not_yet_valid: PartnerTokenRefused        malformed: PartnerTokenRefused        wrong_audience: PartnerTokenRefused        wrong_issuer: PartnerTokenRefused    }}

The test's clock stands at 2026-10-18T09:00:00Z, and each token was made with RFC 8032's first test key, whose public half the key set holds. The refused tokens are the classic attacks: one past its expiry, one that names alg: none, one signed with HMAC SHA-256 keyed with the public key (mistaking a public key for a shared secret), one whose claims were changed after signing, and one issued for another audience:

example
test "a partner's token signs in only while it holds" {    clock: "2026-10-18T09:00:00Z"    var partner_signing_keys: List<Jwk> = attempt partner_keys(        "{\"keys\": [{\"kty\": \"OKP\", \"crv\": \"Ed25519\", \"x\": \"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo\", \"kid\": \"2026-10\"}]}"    )    var genuine: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzE3NjAwLCJuYmYiOjE3OTIzMTM5NDAsImlhdCI6MTc5MjMxMzk0MCwianRpIjoiYTFiMmMzIn0.nOqN7320O3T8e_AyF7XNt32jmgjE8VCU7GVHL89ZUeiRcU3gGM3EgiNxVp3_y422De1PZWd2tZ0LuSqdfm6QCg"    var partner_claims: JwtClaims = attempt partner_sign_in(genuine, partner_signing_keys)    assert partner_claims.sub == "member-42"    assert partner_claims.aud == ["clinic"]    var past_expiry: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzEzOTY5LCJqdGkiOiJhMWIyYzMifQ.0XDMfIlEkopB8bJAJCe95i2zQMGu9mbv_kUtc1QC0xMm5wxmsS9munCCG36_4NkaNcfDKNgaGpswq5QNuh_KDw"    assert partner_sign_in(past_expiry, partner_signing_keys) fails PartnerTokenExpired    var alg_none_token: Jwt = "eyJhbGciOiJub25lIiwia2lkIjoiMjAyNi0xMCJ9.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzE3NjAwLCJuYmYiOjE3OTIzMTM5NDAsImlhdCI6MTc5MjMxMzk0MCwianRpIjoiYTFiMmMzIn0.bUBJ-HYfcv-x-XVebqotpXUryxrUsu15W2sejqXFebmY2OB3-XXAQIWG71XLjOBQDrJnDyfN4RTsPW20OqqBDA"    assert partner_sign_in(alg_none_token, partner_signing_keys) fails PartnerTokenRefused    var shared_secret_forgery: Jwt = "eyJhbGciOiJIUzI1NiIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoiY2xpbmljIiwiZXhwIjoxNzkyMzE3NjAwLCJuYmYiOjE3OTIzMTM5NDAsImlhdCI6MTc5MjMxMzk0MCwianRpIjoiYTFiMmMzIn0.LEqGD4Thr8CB21DsacQjPDe-b_CRUkja0n3gjc6sT7M"    assert partner_sign_in(shared_secret_forgery, partner_signing_keys) fails PartnerTokenRefused    var altered: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTEiLCJhdWQiOiJjbGluaWMiLCJleHAiOjE3OTIzMTc2MDAsIm5iZiI6MTc5MjMxMzk0MCwiaWF0IjoxNzkyMzEzOTQwLCJqdGkiOiJhMWIyYzMifQ.nOqN7320O3T8e_AyF7XNt32jmgjE8VCU7GVHL89ZUeiRcU3gGM3EgiNxVp3_y422De1PZWd2tZ0LuSqdfm6QCg"    assert partner_sign_in(altered, partner_signing_keys) fails PartnerTokenRefused    var for_the_pharmacy: Jwt = "eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUuY29tIiwic3ViIjoibWVtYmVyLTQyIiwiYXVkIjoicGhhcm1hY3kiLCJleHAiOjE3OTIzMTc2MDAsIm5iZiI6MTc5MjMxMzk0MCwiaWF0IjoxNzkyMzEzOTQwLCJqdGkiOiJhMWIyYzMifQ.ysn61bX4ZDaJ7OCiuPNtYwJ1POMD7b1tVLIfZsDS1oimtwY3XgyhZDpLSkDph6j3fol8nv4KPZQ0g7Ai9pd4Bw"    assert partner_sign_in(for_the_pharmacy, partner_signing_keys) fails PartnerTokenRefused}

Three kinds of key, one algorithm each

A Jwk's kty is one of:

  • OKP, an Ed25519 key (crv and x), used with EdDSA;
  • RSA (n and e), used with RS256;
  • EC, a P-256 key (crv, x and y), used with ES256.

A token is tried only against keys used with the algorithm its header names, and each with that key's own algorithm. So none, HS256, an RS256 token naming an EC key's id, and the reverse, all fail invalid_signature before any key is tried. A key whose own alg names an algorithm other than the token's is never tried. A key set naming any other algorithm, such as RS384, RS512, PS256 or ES384, doesn't read. HS256 needs a shared secret held as data, which waits for keys held as data (Language gaps).

Keys come only from the call

A header's jwk, jku, x5u and x5c are never read. A header's kid narrows the keys tried to those with that id; with none, every key is tried. A header with crit fails invalid_signature (RFC 7515 §4.1.11), and a nested token (cty: JWT) is malformed.

The signature first

Nothing the claims say is read until a key has verified the signature, strictly. Then come the times, then the issuer, then the audience.

  • Ed25519 is checked as verify_signature checks it.
  • RS256 is RSASSA-PKCS1-v1_5 with SHA-256. It is checked by encoding the whole padded digest and comparing it (RFC 8017 §8.2.2), never by parsing it, and the signature must be exactly as long as the modulus.
  • An ES256 signature is R and S, 64 octets (RFC 7518 §3.4), each from 1 to below the group's order. A high S is accepted, as RFC 7518, p256 and RFC 7515 §A.3's own example allow. That matters only to something keyed on a token's text, and Jadpo keeps no token.

Times

The times have 30 seconds' leeway: exp must be later than now, and nbf and iat no later. A token that breaks one fails expired or not_yet_valid. A token with no exp is malformed, so no token lives for ever. A numeric date is a whole number of seconds within an Instant's range; a fraction is malformed.

verify_jwt reads the clock (Op::reads_clock), as clock.now does, so a function calling it is type.clock_context. In a test, it reads the test's fixed clock.

Issuer and audience

issuer: and audience: are each optional. The issuer is checked only when the call names one, so wrong_issuer needs its arm only then. The audience is always checked: a token with an audience is refused unless the call names one of them, and a token with none is refused when the call names one (RFC 7519 §4.1.3). Both compare exactly, as RFC 7519 compares a StringOrURI.

One reading of the JSON

The header and the claims must each be one JSON object with no member named twice at any depth, or the token is malformed (RFC 7515 §5.2 and RFC 7519 §7.2 allow refusing one). So no other reader can take a different value from the same token.

Refused tokens

Refusals are invalid_value failures, as every standard operation's conditions map to. Nothing here makes a refused token unauthenticated.

Your own claims: as:

as: reads a token's claims into an Object your application declares. Its fields are read as json.read reads an object's. A claim missing, of the wrong kind or breaking a rule is mismatch, and its first problem fills a problem: DecodeProblem field as json.read's does. Claims the type doesn't name are ignored, as RFC 7519 §4 requires of claims not understood, though a nested object it names is read strictly.

Anything but your application's Object (Text, JwtClaims, a list) is type.mismatch. Nothing is read as the type until the signature, the times, the issuer and the audience hold, and mismatch needs its arm only when as: is given.

A field the type has for one of the seven registered claims (iss, sub, aud, exp, nbf, iat, jti) is filled with the value the call checked, exactly as JwtClaims holds it. So one call gives a token's subject and its own claims. iss, sub and jti are text, aud a list of text, and exp, nbf and iat an Instant. A field declared as another type (sub: Int, exp: Text) is type.mismatch. One built on its type (sub: MemberId) keeps its own rules, and one without ? that the token leaves out is mismatch (required).

The token types

These types hold what verify_jwt takes and gives.

Jwt is a JWS in compact form: three parts of unpadded Base64url in one spelling, none empty, so an unsecured token (RFC 7519 §6) is never one, and at most 8,192 characters. A token is a credential, so it is a secret value (Secret values), never shown.

Jwk holds one public key and never a secret. Its parts are kty, crv, x, y, n, e, kid (1 to 256 printable ASCII characters), alg, use_ (sig, sent as RFC 7517's use) and x5c, each but kty optional. Three rules across them hold a key to its kind: parts_of_its_kty (its kind's parts and no other's), algorithm_of_its_kty (an alg, where given, is its kind's) and a_usable_key.

  • Ed25519's x is 32 bytes as unpadded Base64url in one spelling.
  • An RSA modulus is 2,048 to 4,096 bits, odd and in its fewest octets. Under 2,048 bits can be factored (RFC 7518 §3.3 requires 2,048), and 4,096 is the most rsa checks by default, so a key set can't make checking a token cost without bound. The exponent is odd, from 3 to 2^32 - 1, in its fewest octets.
  • A P-256 point is checked to be on the curve, both by the type's rule and by p256 again before it is used, each coordinate 32 octets in full (RFC 7518 §6.2.1.2).

A Jwk is shown by its key id, else its x, else its n.

Members a Jwk doesn't hold are ignored, as RFC 7517 §4 requires (Microsoft's key set sends issuer and cloud_instance_name, and Microsoft's and Auth0's send x5t). It is the one standard object that ignores them. But a private key's or a shared secret's members (d, p, q, dp, dq, qi, oth, k), and key_ops, which limits a key in a way a Jwk doesn't hold, make JSON not a Jwk. So a key set read from outside can't put a secret in a program. A key set whose keys don't all read is mismatch as a whole, as any list is.

A Jwk's x5c is carried, and held to the key. It is the certificates' DER in standard Base64, as RFC 7517 §4.7 sends them, read with encoding.from_base64 and crypto.read_certificate. A key with certificates is tried only when its first certificate reads strictly and holds the same public key (RFC 7517 §4.7's MUST). Nothing checks the chain, which x5c alone doesn't make trusted. x5u, x5t and a header's x5c are never read.

JwtClaims holds RFC 7519's seven registered claims by their own names: aud is a list (one audience is read as a list of one), exp is always there, and each text claim is one line of 1 to 1,024 characters. It is sent with those names and Jadpo's instants, not numeric dates, and leaves out the claims it doesn't have. It is stored in a column per claim, and shown by its subject, else its issuer, else its id, else its expiry. A token's other claims (scope, email) are read with as:.

OAuthScope is one RFC 6749 scope-token of 1 to 256 characters. The enum Policy generates from the scopes authentication.jadpo declares narrows this type to them, as a closed subset, rather than reusing its name.

OidcClaims holds OpenID Connect Core's subject (1 to 255 printable ASCII characters) and the claims a sign-in reads: name and preferred_username as lines, given_name and family_name as the standard types, email, email_verified, phone_number, phone_number_verified and picture. gender stays out, by the rule in The standard types. birthdate, address, locale, zoneinfo and updated_at aren't held, since a sign-in doesn't need them. JSON naming a claim it doesn't hold isn't an OidcClaims, as for every object. It is shown by its name, else its email, else its subject.

crypto.verify_jwt and crypto.read_certificate are built, and read and check Google's, Microsoft's and Auth0's key sets and tokens. They are tested against RFC 7515's, RFC 7519's, RFC 7520's and RFC 8037's examples and the classic attacks under each algorithm. Neither reads randomness, and both build for WebAssembly. Both only verify, so RUSTSEC-2023-0071 (Marvin), which leaks an RSA private key through the timing of decryption and signing, doesn't touch them. RS384, RS512, PS256, ES384 and the other algorithms aren't built, and neither is OAuthScope.

Markdown for agents: /docs/signed-tokens.md.Built from Jadpo main at a707efb.