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:
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:
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 (crvandx), used withEdDSA;RSA(nande), used withRS256;EC, a P-256 key (crv,xandy), used withES256.
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_signaturechecks 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,
p256and 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
xis 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
rsachecks 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
p256again 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_jwtandcrypto.read_certificateare 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 isOAuthScope.
Markdown for agents: /docs/signed-tokens.md.Built from Jadpo main at a707efb.