GHSA-7vr2-2fgp-9w7p: High severity pip/authlib vulnerability

Published Oct 27, 2026
·
Updated

Summary

OctKey.importkey() carries the only guard that stops an asymmetric public key from being used as an HMAC secret in authlib.jose. That guard is rawkey.startswith(POSSIBLEUNSAFEKEYS) — anchored at byte 0 — while the loader that consumes the identical bytes on the asymmetric path (cryptography.hazmat.primitives.serialization.loadpempublickey / loadpemx509certificate) skips anything before the -----BEGIN line. A PEM public key or X.509 certificate carrying one leading byte — a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line — is therefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the issuer's public key then mints an HS256 token with arbitrary claims that jwt.decode() accepts as authentic.

This is an incomplete fix of CVE-2024-37568 / GHSA-5357-c2jx-v7qh. That advisory's fix is exactly this guard (git log -S 'POSSIBLEUNSAFEKEYS' -- authlib/jose/rfc7518/octkey.py returns a single commit, 3bea812 "fix: prevent OctKey to import ssh/rsa/pem keys", referencing issue #654), and the original attack works again unchanged whenever the stored PEM does not start at offset 0.

Reproduced end-to-end on 1.8.0, the newest release (published 2026-08-30), and on origin/main (c529a61), with cryptography 50.0.2.

Preconditions

Both are deployment properties; neither is attacker-controlled at request time, and the attacker needs no secret and no user interaction.

1. The relying party uses the documented module-level jwt object. from authlib.jose import jwt is built at authlib/jose/init.py:55-72 with the full list ["HS256","HS384","HS512","RS256",...,"EdDSA"], and JsonWebToken.decode (authlib/jose/rfc7519/jwt.py:80) exposes no algorithms parameter, so a caller of the documented object has no way to narrow it. This is the first code sample in docs/jose/jwt.rst:21-31.

This hazard is already documented: docs/jose/jwt.rst:73-79 says "This decoding method is insecure. By default jwt.decode parses the alg header. This allows symmetric macs and asymmetric signatures.", and docs/jose/jwt.rst:94-98 says "You should never combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes." The point of this report is not that the mixed list exists — it is that the octkey.py:81 backstop shipped for precisely that documented hazard does not hold.

2. The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with -----BEGIN . Each of the following forms was confirmed to forge, and each also still verifies a genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken: one leading newline — what a triple-quoted Python literal produces one leading space four-space indentation — an indented YAML/JSON/TOML config block a UTF-8 BOM — a Windows-editor artifact a leading comment line all of the above applied to an X.509 CERTIFICATE PEM instead of a PUBLIC KEY PEM

A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric verification through this path, so I exclude it as a non-deployable configuration and mention it only as further evidence of the recogniser/loader gap.

Details

authlib/jose/rfc7518/octkey.py:10-17

python POSSIBLEUNSAFEKEYS = ( b"-----BEGIN ", b"---- BEGIN ", b"ssh-rsa ", b"ssh-dss ", b"ssh-ed25519 ", b"ecdsa-sha2-", )

authlib/jose/rfc7518/octkey.py:78-84

python rawkey = tobytes(raw)

security check if rawkey.startswith(POSSIBLEUNSAFEKEYS): raise ValueError("This key may not be safe to import")

key = cls(rawkey=rawkey, options=options)

bytes.startswith is anchored. The loader on the asymmetric path is not: authlib/jose/rfc7517/cryptographykey.py:22-29 calls loadpempublickey / loadpemx509certificate, both of which scan forward for the armour header. So the set of byte strings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference is exploitable.

Reach, for a caller of the documented object:

authlib/jose/rfc7518/jwsalgs.py:59 — HMACAlgorithm.preparekey(rawdata) returns OctKey.importkey(rawdata); this is the whole of the HMAC key handling. authlib/jose/rfc7519/jwt.py:80 — JsonWebToken.decode(self, s, key, claimscls=None, claimsoptions=None, claimsparams=None); the algorithm comes from the token header and is checked only against the instance's list. authlib/jose/init.py:55-72 — that list contains both HS\ and RS\/ES\/PS\/EdDSA.

So octkey.py:81 is the only thing between a mixed allow-list and using the public key as an HMAC secret, and it is bypassed by a single leading byte.

PoC

poc-authlib-e2e-http.py stands up a Flask API whose only authentication is the documented jwt.decode(token, PUBLICKEY), where PUBLICKEY is the issuer's RSA public key embedded as a triple-quoted Python literal (hence one leading newline). The attacker is given only that public key.

authlib=1.8.0 cryptography=50.0.2 flask=3.1.3 guard = authlib/jose/rfc7518/octkey.py:81 rawkey.startswith(POSSIBLEUNSAFEKEYS) call = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no algorithms param] algs = authlib/jose/init.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])

[baseline 1] genuine RS256 token, role=user -> (403, '{"error":"not admin","sub":"alice"}') [baseline 2] genuine RS256 token, role=admin -> (200, '{"data":"DBROOTPASSWORD=hunter2; STRIPESK=sklivedeadbeef","ok":true,"sub":"root"}') [CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: badsignature: "}') [CONTROL 2 ] HS256 forged with CANONICAL pubkey -> BLOCKED at key import: ValueError: This key may not be safe to import [CONTROL 3 ] JsonWebToken(['RS256']) vs forged token -> REJECTED: UnsupportedAlgorithmError

ATTACKER INPUT: only IDPPUBCANONICAL (public by definition), no private key. forged token header: {'alg': 'HS256', 'typ': 'JWT'} [ATTACK ] HS256 forged with the PUBLIC key -> (200, '{"data":"DBROOTPASSWORD=hunter2; STRIPESK=sklivedeadbeef","ok":true,"sub":"attacker"}')

The three controls establish that the guard does work on a canonical PEM (so the leading byte is load-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an effective mitigation.

Per-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds on the exact same bytes:

RSA-2048/RS256 canonical PEM (CONTROL A) loader=loadpempublickey startswithguard=True RS/ES-verify=OK BLOCKED:ValueError indented PEM (4 spaces) loader=loadpempublickey startswithguard=False RS/ES-verify=OK FORGED sub=superadmin role=admin UTF-8 BOM prefix loader=loadpempublickey startswithguard=False RS/ES-verify=OK FORGED sub=superadmin role=admin leading comment line loader=loadpempublickey startswithguard=False RS/ES-verify=OK FORGED sub=superadmin role=admin leading blank line loader=loadpempublickey startswithguard=False RS/ES-verify=OK FORGED sub=superadmin role=admin leading single space loader=loadpempublickey startswithguard=False RS/ES-verify=OK FORGED sub=superadmin role=admin DER (SPKI) bytes loader=loadderpublickey startswithguard=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.

Steps to reproduce

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.octkey.POSSIBLEUNSAFEKEYS does not match those bytes with startswith, while cryptography.hazmat.primitives.serialization.loadpempublickey loads them. 4. from authlib.jose import jwt then tok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutatedpem) 5. jwt.decode(tok, mutatedpem) — verification succeeds and the claims are returned.

Impact

Cryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm confusion). An unauthenticated attacker who knows only the issuer's public verification key — public by definition, normally served at /.well-known/jwks.json or in IdP metadata — forges tokens with arbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP 200 and the application's admin secrets for sub=attacker.

Authlib had 103,356,930 PyPI downloads in the last month (pypistats /api/packages/authlib/recent, read 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented footgun it backstops.

Two notes for triage, in the interest of completeness:

authlib.jose emits AuthlibDeprecationWarning ("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented in docs/jose/jwt.rst, and still carries the download volume above. joserfc is not affected by this mechanism. 1.7.5 was checked: keys carry an explicit kty in the data model, so jwt.decode with an RSAKey/ECKey and alg=HS256 raises InvalidKeyTypeError for every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what makes the class unrepresentable there.

Suggested remediation

Either of these closes the class rather than one more byte form:

1. Decide key family by type, not by bytes. Attempt loadpempublickey / loadpemx509certificate / loadsshpublickey / loadderpublickey on the candidate and reject it as an oct key if any of them succeeds. This is the approach that makes joserfc immune and it cannot drift out of step with the loader, because it is the loader. 2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check (any(m in rawkey for m in POSSIBLEUNSAFEKEYS)) and add the -----BEGIN CERTIFICATE----- and DER SubjectPublicKeyInfo cases. This is strictly weaker than (1): the recogniser can drift again the next time cryptography becomes more tolerant.

Separately, and independent of this report: giving JsonWebToken.decode an algorithms= parameter would let callers of the documented module-level object apply the mitigation the docs recommend without constructing a second JsonWebToken.

Credit

Found by zer0d4y5. Happy to test a patch branch before release.

Affected Software

1 affected componentFixes available
pip/authlib>=1.3.1<=1.8.0
1.9.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/authlib to a version that resolves this vulnerability.

    Fixed in 1.9.0
  2. Configuration

    Do not use the module-level mixed-algorithm jwt object for verification; construct JsonWebToken(['RS256']) or an equivalent allow-list containing only the expected asymmetric algorithm(s).

    Authlib JsonWebToken algorithms = RS256

Event History

Oct 27, 2026
Advisory Published
via GitHub·03:38 PM
Data Sourced
via GitHub·03:38 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed to this issue?

Deployments using authlib.jose jwt.decode() are exposed when they use an asymmetric issuer public key as verification material in a context where HS256 tokens can be accepted, and the stored PEM public key or X.509 certificate has bytes before its -----BEGIN line.

2

What does an attacker need to exploit it?

The attacker needs only the issuer's public key. They can use it as an HMAC secret to create an HS256 token with arbitrary claims that jwt.decode() accepts as authentic.

3

How can I determine whether my key material triggers the bypass?

Inspect the exact bytes of the configured PEM public key or certificate. It is affected if anything precedes the -----BEGIN line, including a newline, space, indentation, UTF-8 BOM, or comment line.

4

What can be done while a complete fix is unavailable?

Ensure configured PEM public keys and certificates begin exactly with the -----BEGIN line at byte offset 0. Remove leading whitespace, BOMs, comments, and other prefix bytes from the stored key material.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203