GHSA-97R5-PG8X-P63P
Vulnerability from github – Published: 2026-05-22 17:48 – Updated: 2026-05-22 17:48Summary
Flask-Security-Too 5.8.0's OAuth reauthentication flow can mark a session as fresh after verifying an OAuth account that belongs to a different user.
If an attacker can operate an already-authenticated but stale victim
session, they can complete OAuth verification using their own OAuth
identity. The victim session is then treated as recently
reauthenticated, allowing freshness-protected account actions to
proceed. This was reproduced against the built-in /change-username
route.
### Details
The issue is in the OAuth verification callback.
_oauth_response_common() resolves the OAuth provider identity to a
Flask-Security user:
flask_security/oauth_glue.py:101-108
oauth_verify_response() then accepts any resolved user and updates
the current session freshness timestamp:
flask_security/oauth_glue.py:182-214flask_security/oauth_glue.py:201-204
The missing check is that the OAuth-resolved user must match the current authenticated session user. In the failing case:
- current session user:
victim@example.com - OAuth verified user:
attacker@example.com - session marked fresh: yes
So the attacker is not logging in as the victim, but they are satisfying the victim session's reauthentication requirement with a different account.
### PoC
Tested version:
Flask-Security-Too 5.8.0- tag
5.8.0 - commit
08288dff6907e413d848a16aaf43fc2c2b2a3b72
Used a minimal Flask app with:
```python SECURITY_OAUTH_ENABLE = True SECURITY_OAUTH_BUILTIN_PROVIDERS = ["github"] SECURITY_FRESHNESS = timedelta(seconds=1) SECURITY_FRESHNESS_GRACE_PERIOD = timedelta(seconds=0) SECURITY_USERNAME_ENABLE = True SECURITY_CHANGE_USERNAME = True
The OAuth provider was replaced with a localhost mock provider returning attacker@example.com. This avoids hitting a live third-party provider while still exercising Flask-Security-Too's real OAuth verification handler.
Reproduction steps:
- Log in as victim@example.com.
- Wait until the session is no longer fresh.
- Confirm POST /change-username is blocked with 401 and reauth_required=true.
- Start OAuth verification with POST /login/oauth-verify-start/ github.
- Complete the callback with an OAuth identity for attacker@example.com.
- Confirm the session is still for victim@example.com, but fs_paa has been updated.
- Retry POST /change-username.
- The victim user's username is changed successfully.
Observed result:
{ "pre_bypass_status": 401, "pre_bypass_reauth_required": true, "attacker_identity": "attacker@example.com", "oauth_verify_response_status": 302, "post_bypass_change_username_status": 200, "final_email": "victim@example.com", "final_username": "victimowned1777878574", "direct_impact_verified": true }
Note: CSRF was disabled in the local harness only to keep the test focused on the reauthentication check. This is not a CSRF bypass report.
This bypasses Flask-Security-Too's freshness/reauthentication boundary.
Applications using OAuth verification together with freshness- protected account operations may allow a stale victim session to be refreshed using a different user's OAuth account. In my test, this allowed the victim account's username to be changed through Flask- Security-Too's built-in /change-username route.
A likely fix is to reject OAuth verification unless the resolved OAuth user matches current_user before updating session["fs_paa"].
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "Flask-Security-Too"
},
"ranges": [
{
"events": [
{
"introduced": "5.8.0"
},
{
"fixed": "5.8.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-46715"
],
"database_specific": {
"cwe_ids": [
"CWE-287"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-22T17:48:54Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\n Flask-Security-Too 5.8.0\u0027s OAuth reauthentication flow can mark a\n session as fresh after verifying an OAuth account that belongs to a\n different user.\n\n If an attacker can operate an already-authenticated but stale victim\n session, they can complete OAuth verification using their own OAuth\n identity. The victim session is then treated as recently\n reauthenticated, allowing freshness-protected account actions to\n proceed. This was reproduced against the built-in `/change-username`\n route.\n\n ### Details\n\n The issue is in the OAuth verification callback.\n\n `_oauth_response_common()` resolves the OAuth provider identity to a\n Flask-Security user:\n\n - `flask_security/oauth_glue.py:101-108`\n\n `oauth_verify_response()` then accepts any resolved user and updates\n the current session freshness timestamp:\n\n - `flask_security/oauth_glue.py:182-214`\n - `flask_security/oauth_glue.py:201-204`\n\n The missing check is that the OAuth-resolved user must match the\n current authenticated session user. In the failing case:\n\n - current session user: `victim@example.com`\n - OAuth verified user: `attacker@example.com`\n - session marked fresh: yes\n\n So the attacker is not logging in as the victim, but they are\n satisfying the victim session\u0027s reauthentication requirement with a\n different account.\n\n ### PoC\n\n Tested version:\n\n - `Flask-Security-Too 5.8.0`\n - tag `5.8.0`\n - commit `08288dff6907e413d848a16aaf43fc2c2b2a3b72`\n\n Used a minimal Flask app with:\n\n ```python\n SECURITY_OAUTH_ENABLE = True\n SECURITY_OAUTH_BUILTIN_PROVIDERS = [\"github\"]\n SECURITY_FRESHNESS = timedelta(seconds=1)\n SECURITY_FRESHNESS_GRACE_PERIOD = timedelta(seconds=0)\n SECURITY_USERNAME_ENABLE = True\n SECURITY_CHANGE_USERNAME = True\n\n The OAuth provider was replaced with a localhost mock provider\n returning attacker@example.com. This avoids hitting a live third-party\n provider while still exercising Flask-Security-Too\u0027s real OAuth\n verification handler.\n\n Reproduction steps:\n\n 1. Log in as victim@example.com.\n 2. Wait until the session is no longer fresh.\n 3. Confirm POST /change-username is blocked with 401 and\n reauth_required=true.\n 4. Start OAuth verification with POST /login/oauth-verify-start/\n github.\n 5. Complete the callback with an OAuth identity for\n attacker@example.com.\n 6. Confirm the session is still for victim@example.com, but fs_paa has\n been updated.\n 7. Retry POST /change-username.\n 8. The victim user\u0027s username is changed successfully.\n\n Observed result:\n\n {\n \"pre_bypass_status\": 401,\n \"pre_bypass_reauth_required\": true,\n \"attacker_identity\": \"attacker@example.com\",\n \"oauth_verify_response_status\": 302,\n \"post_bypass_change_username_status\": 200,\n \"final_email\": \"victim@example.com\",\n \"final_username\": \"victimowned1777878574\",\n \"direct_impact_verified\": true\n }\n\n Note: CSRF was disabled in the local harness only to keep the test\n focused on the reauthentication check. This is not a CSRF bypass\n report.\n\n This bypasses Flask-Security-Too\u0027s freshness/reauthentication\n boundary.\n\n Applications using OAuth verification together with freshness-\n protected account operations may allow a stale victim session to be\n refreshed using a different user\u0027s OAuth account. In my test, this\n allowed the victim account\u0027s username to be changed through Flask-\n Security-Too\u0027s built-in /change-username route.\n\n A likely fix is to reject OAuth verification unless the resolved OAuth\n user matches current_user before updating session[\"fs_paa\"].",
"id": "GHSA-97r5-pg8x-p63p",
"modified": "2026-05-22T17:48:54Z",
"published": "2026-05-22T17:48:54Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/pallets-eco/flask-security/security/advisories/GHSA-97r5-pg8x-p63p"
},
{
"type": "PACKAGE",
"url": "https://github.com/pallets-eco/flask-security"
}
],
"schema_version": "1.4.0",
"severity": [],
"summary": "Flask-Security-Too OAuth reauthentication freshness bypass via cross- user OAuth identity acceptance"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.