Feb 23, 2026·6 min read·8 visits
Authlib < 1.6.6 failed to bind OAuth 'state' data to user sessions when using server-side caching (e.g., Redis). Attackers could generate a valid state, send the callback URL to a victim, and hijack the account via Login CSRF.
OAuth is a complex beast, and the `state` parameter is its primary defense against Cross-Site Request Forgery (CSRF). In Authlib versions prior to 1.6.6, a critical logic error occurred when developers opted for performance by storing OAuth states in a backend cache (like Redis or Memcached) instead of cookies. The library failed to cryptographically bind the cached state to the user's browser session. This decoupling allowed attackers to generate a valid OAuth flow, harvest the URL, and trick a victim into consuming it—effectively performing a Login CSRF or account linking attack with a single click.
If you build Python web applications, you probably use Authlib. It is the Swiss Army knife of OAuth and OpenID Connect, powering authentication for thousands of Flask, Django, and Starlette apps. It abstracts away the misery of the OAuth 2.0 handshake, handling the redirect dances and token exchanges so you don't have to.
The cornerstone of OAuth security is the state parameter. It is a random token generated by the client (your app) to prevent CSRF. It says, "I am the one who started this request, and I am the one finishing it." Without it, anyone could force your browser to log in to their account, or worse, link their rogue Google account to your administrative profile.
Here is the twist: Authlib handled this perfectly fine in its default mode (storing state in cookies). But developers love speed. We love scaling. So, we switch the storage backend to Redis or Memcached to keep our cookies small. In Authlib versions before 1.6.6, flipping that switch was like locking your front door but leaving the keys taped to the outside of the window. The library stopped checking who owned the state, and only checked if the state existed.
To understand this bug, imagine a valet parking service. Normally, you hand over your keys and get a ticket (the state). When you return, you show your ticket, and the valet checks two things: 1) Is this a valid ticket? and 2) Does the face matching this ticket look like the guy who dropped off the car?
Authlib's cache-backed implementation skipped step 2.
In framework_integration.py, the logic for retrieving state data looked something like this (simplified):
# The Vulnerable Logic
def get_state_data(self, session, state):
key = f"_state_{self.name}_{state}"
if self.cache:
# LOOKUP ONLY BY KEY
value = self.cache.get(key)
return valueDo you see the issue? The get_state_data function takes a session object, but it ignores it if a cache is configured. It constructs a lookup key based entirely on the state parameter found in the URL.
This means the state was no longer a secret bound to the user's browser session. It became a global, floating token. If I, the attacker, start a login flow, Authlib writes _state_google_XYZ123 to Redis. If I then send you a link containing state=XYZ123, Authlib looks up that key in Redis, finds it, and says, "Looks legit! Proceed."
The fix, introduced in commit 2808378611dd6fb2532b189a9087877d8f0c0489, reveals exactly how the maintainers patched the hole. They implemented a "double-binding" strategy. Even if the heavy data lives in Redis, a reference must exist in the user's session cookie.
Here is the critical diff:
def get_state_data(self, session, state):
key = f'_state_{self.name}_{state}'
+ # VERIFY THE SESSION HAS THIS KEY
+ if not session.get(key):
+ return None
+
if self.cache:
return self.cache.get(key)Before the patch, the code blindly trusted the cache. After the patch, the code first checks session.get(key). If the user's browser doesn't have the specific cookie indicating they started this flow, the server returns None, and the attack fails. It forces the state to exist in both the shared cache (Redis) and the private session (Cookie).
Let's weaponize this. We are going to perform a classic Login CSRF attack. The goal is to force the victim to log in using the attacker's credentials, or to link the attacker's social profile to the victim's existing account.
The Setup:
target-app.com/login/google.state=EVIL_STATE, stores it in Redis, and redirects the Attacker to Google.The Trap:
4. Attacker constructs the callback URL: https://target-app.com/authorize?code=ATTACKER_CODE&state=EVIL_STATE.
5. Attacker sends this link to the Victim via email/chat.
The Execution:
6. Victim clicks the link.
7. Target App receives the request. It extracts state=EVIL_STATE.
8. Vulnerable Logic checks Redis for _state_google_EVIL_STATE. It exists! (Because the attacker put it there).
9. Target App logs the user in.
The Payoff: If the application supports "Social Account Linking," and the victim was already logged in, the attacker's Google account is now linked to the victim's profile. The attacker can now log in as the victim at any time using their own Google credentials.
While NVD rates this as a Medium (5.7) because it requires user interaction, the practical impact is often Critical. Identity is the perimeter. If I can compromise your identity layer, I don't need an RCE.
In scenarios where this vulnerability is used for Account Linking, the impact is persistent Account Takeover (ATO). The attacker gains a permanent backdoor into the user's account without changing the password.
In Login CSRF scenarios (where the victim is logged in as the attacker), the impact is subtle but dangerous. The victim might upload sensitive documents or credit card details, thinking they are in their own account, effectively handing that data directly to the attacker. For SaaS platforms handling sensitive data, this is a privacy nightmare.
The remediation is straightforward: Update Authlib to version 1.6.6 or higher.
If you cannot update immediately, you must modify your FrameworkIntegration or custom OAuth client setup. You need to ensure that whatever storage backend you use, you are cross-referencing the state with the user's current session.
> [!NOTE]
> If you are using FileStorage or SQLAlchemy storage for states, check if your implementation relies on FrameworkIntegration. The vulnerability specifically affects the default logic where cache is passed to the registry.
After patching, ensure you rotate all active sessions. The patch changes how states are validated, so pending OAuth flows might fail immediately after deployment (a small price to pay for security).
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Authlib Authlib | < 1.6.6 | 1.6.6 |
| Attribute | Detail |
|---|---|
| Vulnerability Type | CWE-352: Cross-Site Request Forgery (CSRF) |
| Attack Vector | Network (Pre-generated State) |
| CVSS v3.1 | 5.7 (Medium) |
| Impact | Account Takeover / Account Linking |
| Fixed Version | 1.6.6 |
| Affected Component | authlib.integrations.base_client.framework_integration |
The application does not verify that the OAuth state parameter is bound to the user's current session, allowing CSRF attacks.