class

KemalIdentity::JWT::Validator

Inherits KemalIdentity::RequestAuthenticator < Reference < Object

Validates a JSON Web Token presented as a bearer credential.

Off by default, and second on purpose

docs/06-roadmap.md puts opaque tokens first and this second, because a JWT buys one thing — verification without a lookup — and pays for it with the property that matters most in an authentication system: you cannot take it back. Nothing here is wired into Application unless an application asks for it by constructing a Validator. Read RevocationStore before you do; it states the trade-off in full.

This validator verifies tokens minted elsewhere — an identity provider, an API gateway, another service in the estate. It does not mint them. If you are about to issue JWTs to your own clients, ApiTokens::Service is the credential this shard recommends, and it revokes.

What "strict" means here

Every one of these is a documented, exploited JWT failure, and none of them is optional:

AttackWhat stops it
alg: noneno Algorithm can express it; the allow-list refuses the string at boot; alg is compared against the key's
algorithm confusion (RS256 verified as HS256)the key names its algorithm; the token's alg selects nothing
a retired key still acceptedan unknown kid is rejected, never retried against the ring
a token from another service replayed hereiss and aud are required and compared
a token that never expiresexp is required, and max_lifetime bounds how far away it may be
a reset-link token used as an access tokenpurpose is required and compared
clock skew widened into an expiry bypassleeway is bounded at MAX_LEEWAY
a signature over re-encoded claimsverification runs over the received bytes
a multi-megabyte headersize and shape are checked before any parsing

What it deliberately does not check

auth_version is not compared, exactly as in ApiTokens::Service: a password change must not silently break a machine client that has no way to notice. The difference is that a JWT gives you no way to change your mind later either — which is the whole point of the paragraph above.

Constants

DEFAULT_LEEWAY = 30.seconds

Default tolerance for the two clocks disagreeing.

DEFAULT_MAX_BYTESIZE = 8192

Largest token accepted, before anything is decoded or parsed.

A JWT carrying a real claim set is a few hundred bytes. This bound exists so that a hostile Authorization header costs one integer comparison instead of a base64 decode and a JSON parse — the same "shape before I/O" rule the opaque tokens follow.

DEFAULT_MAX_LIFETIME = 1.hour

Default ceiling on how long a token may claim to be valid for.

This is the "very short TTL" half of the revocation trade-off. A token is a standing grant that cannot be withdrawn, so its lifetime is the exposure window for a stolen one.

DEFAULT_PURPOSE = "access"
DEFAULT_PURPOSE_CLAIM = "purpose"

Claim carrying what the token is for, and the value an access token must have.

MAX_LEEWAY = 5.minutes

The most skew that may be configured.

Leeway extends the life of every expired token by its own width, so it is an expiry bypass with a limit on it. Thirty seconds covers NTP-synchronised hosts; anything approaching an hour means the clocks are broken and should be fixed rather than tolerated.

Constructors

new(keyring : Keyring | KeySource, issuer : String, audience : String, algorithms : Array(String), clock : Clock, leeway : Time::Span = DEFAULT_LEEWAY, max_lifetime : Time::Span | Nil = DEFAULT_MAX_LIFETIME, purpose : String | Nil = DEFAULT_PURPOSE, purpose_claim : String = DEFAULT_PURPOSE_CLAIM, revocations : RevocationStore | Nil = nil, accounts : Accounts::Repository | Nil = nil, max_bytesize : Int32 = DEFAULT_MAX_BYTESIZE)

algorithms is an allow-list of alg header values, checked before a key is even selected. It is separate from the keyring's algorithms on purpose: two independent gates on the same value, so that one misconfigured keyring is not enough.

purpose may be set to nil to accept tokens carrying no purpose claim — an explicit decision at the call site, for an issuer you do not control that does not emit one. Understand what it costs: without it, any validly signed token from that issuer authenticates a request, including one minted for a password reset or an email confirmation.

accounts, when given, turns "is this account still allowed in?" back into a lookup: a disabled account stops authenticating immediately instead of at exp. That is a read from storage on every request, which is the cost a JWT was chosen to avoid — the honest accounting is in RevocationStore. keyring may be a fixed Keyring or a KeySource such as JWKS. The difference is visible in exactly one place — an unknown kid asks a JWKS to refetch once, because that is what a key rotation looks like from here.

Source

Class methods

decode_json(segment : String) : Hash(String, JSON::Any) | Nil

Class-level so that JWT.unverified_issuer can reuse exactly this discipline rather than reimplementing it. Neither reads instance state, and a second decoder that agreed almost with this one is how a token means two things to one application.

Source
decode_segment(segment : String) : Bytes | Nil

Base64url, unpadded, per RFC 7515 §2.

Strict about the alphabet rather than lenient: standard-base64 + and /, and = padding, are all rejected. A decoder that accepts several encodings of one token is a decoder two systems can disagree about, and disagreement is where signature- stripping bugs live.

Source

Instance methods

algorithms
Source
audience
Source
authenticate(credential : String | Nil) : Outcome

Resolves the value after the Bearer scheme.

Returns Anonymous when nothing was presented, Failed for everything else, and never raises: every byte here is attacker-controlled.

The order is shape, then signature, then claims, then storage. A token is not parsed for meaning until it has been proven to come from a key we hold — reading claims out of an unverified token is how a validator ends up trusting an attacker's kid.

Source
issuer
Source
leeway
Source
max_lifetime
Source
purpose
Source
purpose_claim
Source
revocations

The jti denylist, when one was configured. Exposed so Sweeper can drop entries whose tokens have expired; see RevocationStore.

Source
validate(credential : String | Nil) : Validated | Failed

The same validation as #authenticate, keeping the claims.

Principal deliberately carries nothing but a subject, an assurance and a time — no email, no name, no groups (docs/03-architecture.md on why). An OpenID Connect callback genuinely needs the rest of the claim set, though, because that is where the provider's assertions live, so this returns both rather than making OIDC::Client re-parse a token this class has already verified.

Everything #authenticate refuses, this refuses identically. It is the same code path.

Source