Skip to content

Enforce JWK metadata when finding verification keys - #769

Open
sj26 wants to merge 3 commits into
jwt:mainfrom
sj26:fix/jwks-verification-metadata
Open

sj26 wants to merge 3 commits into
jwt:mainfrom
sj26:fix/jwks-verification-metadata

Conversation

@sj26

@sj26 sj26 commented Oct 8, 2026 •

Copy link
Copy Markdown

Description

JWT::JWK::KeyFinder currently returns verify_key, dropping the selected JWK's metadata before verification. A correctly signed token can therefore verify despite incompatible alg, use, or key_ops metadata.

Select compatible JWKs and retain algorithm restrictions during verification:

  • alg must match the token's algorithm header exactly, including case, as required by RFC 7517.
  • use must be sig.
  • key_ops must contain verify.

Omitted metadata remains allowed. Incompatible candidates are skipped, including when several keys share an identifier or allow_nil_kid is enabled. If no compatible candidate remains, selection uses the existing loader invalidation path before raising JWT::SignatureError. Direct JWK verification also enforces use and key_ops.

KeyFinder#call returns the JWK when alg is present, so the trusted JWK determines the actual verification algorithm, not just the token header. For example:

# JWK alg: PS256; token header alg: PS256; actual signature: RS256
token.verify_signature!(algorithm: %w[RS256 PS256], key_finder: finder)
# Before: accepted. After: JWT::VerificationError.

Without alg, call still returns raw key material and uses the caller's allowlist as before. key_for(kid, key_field = :kid) keeps its original positional signature and raw-key return type; no new keyword is required. Compatibility note: call now resolves JWKs directly rather than dispatching through key_for, so subclasses customizing token lookup should override call. Applications extracting raw keys themselves must enforce algorithm restrictions. Explicit null metadata is not treated as omission.

This enforces metadata; it does not demonstrate a signature forgery or change cryptographic verification.

Regression tests cover HMAC, RSA, RSA-PSS, EC, multi-algorithm verification, mixed JWKS, loader invalidation and direct JWK usage. Latest validation on Ruby 3.1.2 / OpenSSL gem 3.0.1: the full suite and standalone appraisal each passed with 764 examples, 0 failures, and 12 existing pending. RuboCop passed (94 files, no offenses). The new review regressions failed before the fix (119 examples, 8 failures). A separate RSA check rejected the mismatched RS256 signature while accepting a genuine PS256 signature. RSA-PSS cases use the suite's existing capability guard: removing sign_pss in scratch validation gives 119 examples, 0 failures and 26 expected pending. This simulates the missing API; native legacy OpenSSL validation remains with CI.

Checklist

  • There are tests for the fix or feature added/changed
  • A description of the changes and a reference to the PR has been added to CHANGELOG.md. More details in the CONTRIBUTING.md

@sj26
sj26 force-pushed the fix/jwks-verification-metadata branch from 414b9e0 to 4b66453 Compare October 13, 2026 06:57
@anakinj

anakinj commented Oct 16, 2026

Copy link
Copy Markdown
Member

Note: This review was generated with an AI assistant and lightly curated by me. The findings come from reading the diff alongside jwa.rb, encoded_token.rb and decode.rb; they have not been verified by running code, so please double-check them.

Thanks for picking this up, making sure JWK metadata is respected is a good goal. I think there is a design problem with where the check lives, though, and most of the issues below follow from it.

The core issue

KeyFinder#key_for reduces the JWK to a raw key (jwk.verify_key), dropping the metadata. This PR keeps that and adds a check just before the metadata is lost: it compares the JWK's alg to token.header['alg']. The header is attacker-controlled, so that check only means something if the verifier then uses exactly the header's algorithm.

  • JWT.decode does, because it narrows the allowed algorithms to the header alg (allowed_and_valid_algorithms).
  • EncodedToken#verify_signature! does not. It only uses the header to decide which algorithm to try first (JWA.resolve_and_sort), then tries every allowed algorithm against the raw key.

So on the EncodedToken path the restriction can be bypassed. For example, the JWK is RSA with alg: PS256 and the caller allows ['RS256', 'PS256']. A token with header alg: PS256 but an RS256 signature passes validate_key_usage!, and then the RS256 verifier accepts it against the raw key. This needs a caller that allows several algorithms for the same key type, but that's exactly the case the metadata check is meant to cover.

The library already has a mechanism that binds a key to its algorithm from the trusted side. JWA.create_verifiers handles JWK::KeyBase specially: validate_jwk_algorithms! checks the JWK's alg against the caller's allowlist, and the JWK itself becomes the verifier, so it only verifies with jwk.jwa. If the finder returned the JWK instead of verify_key, alg would be enforced on both paths without consulting the header.

The catch is that KeyBase#jwa currently raises when alg is missing (only EC can fall back to its curve), and many real-world JWKS omit alg. Deciding what an alg-less JWK means as a verifier (presumably: fall back to the caller's allowlist, filtered by key type) is the real design work here.

Other findings

  1. use / key_ops reject the selected key instead of filtering candidates. resolve_key picks the first key whose kid/x5t matches, and only then is the metadata checked. If a set has an enc key and a sig key with the same kid (allowed by RFC 7517) and the enc key comes first, valid tokens now fail with VerificationKeyError. The same applies with allow_nil_kid on a mixed set. This compatibility check belongs in the key matcher so incompatible keys are skipped.
  2. README regression. The README drops the filter! { |key| key[:use] == 'sig' } / select! advice on the basis that the finder now handles it. Because of (1) it doesn't filter, so users following the new README with provider key sets that include encryption keys can get failures the old advice avoided.
  3. No reload on a metadata failure. When a key is found but rejected, the loader is never called with invalidate: true. If a provider fixes alg/use under the same kid, a cached loader keeps failing until the process restarts, whereas a missing kid would trigger a reload. (Skipping incompatible keys during selection, as in (1), would fall through to the existing reload path.)
  4. Explicit null metadata is treated as a restriction. jwk.parameters.fetch(:use, 'sig') returns nil when the field is present but null, so a JWKS that serializes "use": null (or "alg": null, "key_ops": null) is rejected, contrary to "omitted metadata does not restrict the key". jwk[:use] || 'sig' or an explicit nil? check would fix it.
  5. The policy only lives in KeyFinder. Passing a JWK directly as key: to EncodedToken#verify_signature! / JWA.create_verifiers, or calling KeyBase#verify, skips the use/key_ops checks. A method on JWK::KeyBase (e.g. something like usable_for_verification?) that both the finder and the verifier path use would avoid duplicating the rules.
  6. Minor: subclasses overriding key_for. call now passes algorithm:, so a subclass overriding key_for(kid, key_field = :kid) gets ArgumentError: unknown keyword: :algorithm. Probably rare, but worth noting given key_for is public.

Suggested direction

  1. Move the compatibility rules (use, key_ops, key type vs. algorithm) onto JWK::KeyBase, and apply them in resolve_key's matcher so incompatible keys are skipped rather than raised.
  2. Let alg be enforced by create_verifiers by handing the JWK itself to verification, with a defined fallback for keys without alg. Since that changes what key_for returns, it may fit better as a new class or an opt-in than as a change to the existing KeyFinder.

That would address the bypass and findings 1, 2, 3 and 5 together. The case-sensitive alg comparison looks like a deliberate choice backed by RFC 7517, so I'm fine with that.

sj26 and others added 2 commits October 22, 2026 03:51
Filter incompatible candidates before reusing the loader invalidation path. Keep algorithm-bearing JWKs intact for verification, and enforce usage restrictions on direct JWK verification.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0eb70-d1e6-71dd-b774-7a79333b11f8
@sj26

sj26 commented Oct 22, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review. I'm also using an AI assistant on this PR; the reproductions and tests reported below were run by the assistant.

4b50a36 addresses the algorithm bypass, candidate filtering, reload path, and direct-JWK usage checks. Your RSA example now rejects the RS256 signature with PS256 metadata while accepting a genuine PS256 signature. Incompatible candidates are skipped before the existing invalidation path, and use / key_ops checks are shared with direct JWK verification.

I'd appreciate your feedback on three compatibility choices:

  • KeyFinder#call returns the JWK when alg is present, binding verification to its trusted algorithm. Without alg, it still returns raw key material and uses the caller's allowlist as before. Is that return-type change acceptable here, or would you prefer a separate API or opt-in?
  • key_for(kid, key_field = :kid) keeps its original positional signature and raw-key return type, but call now resolves JWKs directly rather than dispatching through overrides of key_for. This is documented, but still changes subclass behavior. Is that boundary acceptable?
  • Explicit null metadata is still rejected rather than treated as omission. The fields have defined string/array types in RFC 7517; would you prefer accepting null as absent for compatibility?

The full suite and standalone appraisal each pass with 764 examples, 0 failures, and 12 existing pending; RuboCop reports 94 files with no offenses. The new regression cases failed before the fix. Simulating the missing RSA-PSS API gives 119 examples, 0 failures, and 26 expected pending; that does not replace native legacy OpenSSL validation.

Could you also approve the pending CI run for this head so the upstream matrix can run?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants