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:
| Attack | What stops it |
|---|---|
alg: none | no 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 accepted | an unknown kid is rejected, never retried against the ring |
| a token from another service replayed here | iss and aud are required and compared |
| a token that never expires | exp is required, and max_lifetime bounds how far away it may be |
| a reset-link token used as an access token | purpose is required and compared |
| clock skew widened into an expiry bypass | leeway is bounded at MAX_LEEWAY |
| a signature over re-encoded claims | verification runs over the received bytes |
| a multi-megabyte header | size 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 tolerance for the two clocks disagreeing.
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 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.
Claim carrying what the token is for, and the value an access token must have.
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
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.
Class methods
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.
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.
Instance methods
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.
The jti denylist, when one was configured. Exposed so Sweeper can drop entries whose
tokens have expired; see RevocationStore.
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.