ALL WRITE-UPS

CVE-2026-104056: Authlib 1.7.2 OAuth/OIDC Discovery Trust Boundary Bypass

CVE-2026-104056: Authlib 1.7.2 OAuth/OIDC Discovery Trust Boundary Bypass

Summary

Authlib's OAuth2/OIDC client integrations (base_client, flask_client, starlette_client, httpx_client) cache the contents of a .well-known OpenID Connect discovery document without binding any of the returned endpoint URLs to the origin of the discovery URL itself. AuthorizationServerMetadata.validate() checks that endpoint URLs are well-formed and use HTTPS, but never checks that they belong to the same issuer/origin the client asked about. As a result, a poisoned or compromised discovery response can redirect a client's entire OAuth flow - authorization codes, client credentials, bearer tokens - to an attacker-controlled domain, and Authlib will accept and use every value verbatim.

This report originally covered three related findings (BUG_01, BUG_02, BUG_03). Following maintainer review, the scope has been narrowed as described below - this writeup reflects that negotiated scope rather than the original three-bug framing.

Affected Files

File Role
authlib/integrations/base_client/sync_app.py load_server_metadata() - caches discovery JSON without origin validation
authlib/integrations/base_client/async_app.py Same pattern, async path
authlib/integrations/base_client/sync_openid.py fetch_jwk_set() / userinfo() consume the unvalidated cache
authlib/integrations/base_client/async_openid.py Same, async path
authlib/integrations/flask_client/apps.py authorize_redirect() / logout_redirect() consume unvalidated metadata
authlib/integrations/starlette_client/apps.py Same, Starlette integration
authlib/oauth2/rfc8414/models.py AuthorizationServerMetadata.validate() - format-only check, no origin binding

Root Cause

load_server_metadata() fetches the discovery document and stores its contents directly into self.server_metadata. No step in this path checks that the endpoints returned share an origin with self._server_metadata_url:

# authlib/integrations/base_client/sync_app.py
def load_server_metadata(self):
    if self._server_metadata_url and "_loaded_at" not in self.server_metadata:
        with self._get_session() as session:
            resp = session.request("GET", self._server_metadata_url, withhold_token=True)
            resp.raise_for_status()
            metadata = resp.json()          # attacker-controlled JSON, if discovery is poisoned

        metadata["_loaded_at"] = time.time()
        self.server_metadata.update(metadata)  # cached verbatim - no origin check
    return self.server_metadata

Even where AuthorizationServerMetadata.validate() is invoked, it only confirms that each endpoint is a well-formed HTTPS URL - it has no concept of "must match the issuer." An endpoint on a completely different, attacker-owned domain passes the same validation as the legitimate one.

Every downstream consumer - fetch_access_token(), fetch_jwk_set(), userinfo(), authorize_redirect(), logout_redirect() - trusts this cache and acts on it: sending real HTTP requests carrying credentials, or redirecting the user's browser.

Proof of Concept

========================================================================
BUG_02  load_server_metadata() skips origin binding entirely
========================================================================
  Discovery URL:    https://legitimate-idp.example.com/.well-known/openid-configuration
  Origin check performed: False   ← NEVER PERFORMED

  Cached metadata (attacker-controlled values stored verbatim):
    authorization_endpoint:
      https://attacker.example.com/steal-code
    token_endpoint:
      https://attacker.example.com/steal-token
    jwks_uri:
      https://attacker.example.com/fake-jwks
    userinfo_endpoint:
      https://attacker.example.com/steal-bearer
    end_session_endpoint:
      https://attacker.example.com/phish-logout

  All subsequent calls to authorize_redirect(), fetch_access_token(),
  fetch_jwk_set(), userinfo(), and logout_redirect() will use these
  attacker-controlled URLs with no rejection from Authlib.

A Burp Suite-driven end-to-end PoC (referenced in the original report) demonstrates the full chain: a poisoned discovery response causes a real client integration to redirect an authorization code and accompanying client credentials to an attacker-controlled domain, with no rejection at any point in Authlib.

Verified against Authlib 1.7.2, Python 3.12.3.

Impact

Attack chain:

  1. Attacker either stands up a malicious/compromised discovery endpoint, or achieves a position to tamper with the discovery response in transit (e.g., a network position where TLS termination is attacker-controlled, a misconfigured or compromised CDN in front of the real IdP, or a compromised upstream that serves the .well-known document).
  2. The discovery JSON returned sets token_endpoint, jwks_uri, userinfo_endpoint, and/or end_session_endpoint to attacker infrastructure.
  3. load_server_metadata() caches it verbatim - no issuer-origin check exists to catch this.
  4. On the next OAuth flow, fetch_access_token() POSTs the authorization code and client secret to the attacker's token_endpoint. fetch_jwk_set() and userinfo() issue server-side requests to attacker infrastructure, with userinfo() carrying the live bearer token.
  5. authorize_redirect() / logout_redirect() send the user's browser to attacker-chosen URLs.

Credential classes exposed: authorization codes, PKCE verifiers, client secrets, refresh tokens, access tokens, id_token hints.

Vendor Discussion

Maintainer response (azmeuk, Jul 30)

azmeuk reply

The maintainer opened by characterizing the original three-finding report as exaggerated, then addressed each point:

On BUG_01: Loopback support is intentional and RFC 8252 §7.3 explicitly permits native apps to open a port on the loopback interface to receive the OAuth redirect, using the http scheme with the loopback IP literal and whatever port the client is listening on. From the maintainer's view, this isn't a bug - it's documented, sanctioned behavior for a specific client-side use case.

On BUG_02: The maintainer stated plainly that the validation demonstrated in the PoC would not have prevented anything: AuthorizationServerMetadata.validate() checks format, not origin. He acknowledged, however, that checking the issuer URL against the origin of the discovery document itself would have real value, and named the missing piece directly - a _validate_metadata_origin method.

On BUG_03: The maintainer agreed Authlib could tighten the URL checks here, but pushed back on the framing: this finding requires reasoning about both the client side and the server side of Authlib simultaneously, when in practice only one role runs at a time. He noted token_endpoint normally comes from developer configuration, not attacker input - and when it does come from the discovery document instead, that just routes back to BUG_02.

Author response (uziii2208, Jul 30)

uziii2208 reply

On BUG_01: Agreed that loopback support is legitimate and that RFC 8252 §7.3 sanctions it for redirect URIs. The rebuttal was narrower than a disagreement over intent: is_secure_transport() is the same function used to validate RFC 8414 authorization server metadata endpoints (issuer, token_endpoint, jwks_uri), which fall under RFC 8414 §2 and are required to use HTTPS with no loopback exception. The actual defect isn't that loopback support exists - it's that one function is answering two RFCs with two different rules at once. The proposed minimal fix: split the check so metadata-endpoint validation stays HTTPS-only, while the loopback allowance is preserved solely for redirect-URI validation under RFC 8252.

On BUG_02: Confirmed agreement that validate() only checks format. The finding, restated precisely, is the missing issuer-origin binding - the exact gap the maintainer had just said would add value. The Burp PoC was offered as end-to-end evidence: a poisoned discovery response redirects authorization codes and client credentials to an attacker-controlled domain with no rejection anywhere in Authlib. The scope proposal: load_server_metadata() should verify that the returned issuer and all endpoint URLs share the origin of the configured server_metadata_url before caching.

On BUG_03: Accepted the maintainer's point about the combined scenario being harder to justify as a standalone attack. Agreed to scope this separately as a defense-in-depth gap: _fetch_token() and _http_post() don't call InsecureTransportError.check() despite it existing for exactly this purpose, but treated it as a hardening issue independent of the discovery path, with severity lowered accordingly.

Resulting proposal: keep BUG_02 as a standalone advisory, since the Burp PoC provides unambiguous end-to-end evidence, and treat BUG_01 and BUG_03 as separate hardening items rather than folding all three into one report.

Bind returned metadata to the origin of the discovery URL before caching, in load_server_metadata():

# authlib/integrations/base_client/sync_app.py
+from authlib.oauth2.rfc8414.models import AuthorizationServerMetadata
+
 def load_server_metadata(self):
     if self._server_metadata_url and "_loaded_at" not in self.server_metadata:
         with self._get_session() as session:
             resp = session.request("GET", self._server_metadata_url, withhold_token=True)
             resp.raise_for_status()
             metadata = resp.json()
+
+        m = AuthorizationServerMetadata(metadata)
+        m.validate()
+        self._validate_metadata_origin(metadata, self._server_metadata_url)
 
         metadata["_loaded_at"] = time.time()
         self.server_metadata.update(metadata)
     return self.server_metadata

_validate_metadata_origin() should confirm that issuer and every returned endpoint URL share the scheme+host(+port) of the configured server_metadata_url, per the issuer-identifier binding requirement implicit in RFC 8414's discovery model (the whole point of pinning discovery to a known issuer is defeated if the returned endpoints aren't required to match it).

Same treatment should be applied to the async path (async_app.py).

  • is_secure_transport() / RFC 8414 endpoints: the loopback-HTTP allowance is correct and required for RFC 8252 redirect URIs, but the same helper also gates RFC 8414 metadata endpoint validation in AuthorizationServerMetadata.validate(), which has no loopback exception. Splitting these into two distinct checks - one for redirect-URI context (loopback allowed), one for metadata-endpoint context (HTTPS only, no exception) - would close this conflation without touching the legitimate native-app use case.
  • _fetch_token() / _http_post() in authlib/oauth2/client.py: neither calls the existing InsecureTransportError.check() before sending. This doesn't produce an independent attack on its own, but it removes a layer of defense-in-depth against exactly the scenario above (an attacker-controlled token_endpoint reached via BUG_02). Adding the check costs nothing and closes the gap for any future path that might introduce an attacker-influenced URL.

References

  • RFC 8414 §2 - Authorization Server Metadata (endpoint HTTPS requirement)
  • RFC 8252 §7.3 - OAuth for Native Apps (loopback redirect URIs)
  • RFC 6749 §3.1, §3.2 - Authorization/Token Endpoint requirements
  • authlib/integrations/base_client/sync_app.py - load_server_metadata()
  • authlib/oauth2/rfc8414/models.py - AuthorizationServerMetadata.validate()
// PRIVACY SHIELD // ZERO-KNOWLEDGE LOCK
Finished reading or stepping away?

Conceal this advisory and lock the viewport in-browser using WebCrypto AES-256-GCM.

COMMS // CH-0x0D
414.1 MHz
> KIRA:

Initializing security matrix...

KIRA // OPERATIVE
> ESC to close