GHSA-f66q-9rf6-8795 Flask-Security-Too: WebAuthn reauthentication freshness bypass via cross-user assertion
An authenticated attacker can bypass WebAuthn reauthentication freshness checks by submitting their own credential into a victim's session, allowing unauthorized sensitive actions.
- Exploited in the wild
- Not known
- Login needed
- Any signed-in user
- Attackable
- Over the internet
- Victim action needed
- A user has to click or open something
- Fix
- None listed
What is GHSA-f66q-9rf6-8795?
GHSA-f66q-9rf6-8795 is an authentication bypass vulnerability in Flask-Security-Too, published on 7 Jul 2026. Any signed-in user can exploit it over the network, and only if a user opens a file or follows a link.
Official description
Summary
Flask-Security-Too 5.8.0 and 5.8.1 mark a session as reauthentication-fresh after processing a WebAuthn assertion whose proven credential belongs to a different user than the currently authenticated session user. The check that GHSA-97r5-pg8x-p63p added on the OAuth reauthentication path (user.email == current_user.email) is missing on the WebAuthn reauthentication path. An attacker who owns any WebAuthn credential registered to any account on the deployment can satisfy a victim session's freshness gate by submitting their own WebAuthn proof into the victim session.
Affected versions
Flask-Security-Too >= 5.8.0, <= 5.8.1 (current main commit 5c44c76e33a20b67d02115e26d2da4bab18c094e).
GHSA-97r5-pg8x-p63p (published 2026-05-22) shipped its fix in 5.8.1 only on oauth_glue.py; webauthn.py was not touched and remains exploitable in 5.8.1.
Privilege required
Authenticated attacker on the same Flask-Security deployment, owning at least one WebAuthn credential of any usage (first / secondary / verify) that is registered to their own account. The attacker also needs the ability to drive HTTP requests against the WebAuthn endpoints inside the victim session (e.g. a separate gadget such as CSRF + cookie-based auth, an XSS that doesn't reach the cookie itself but can move the session through endpoints, or an existing session-fixation gadget; or the rarer but easier case of an attacker who has direct access to the victim's not-yet-fresh session via a shared browser). The point of the freshness gate is to defend exactly that "I have the session but it isn't fresh enough to do sensitive things" position, so any context in which freshness would have protected the victim is also the context in which this bypass matters.
Vulnerable code
flask_security/webauthn.py:846-889 (commit
5c44c76e33a20b67d02115e26d2da4bab18c094e):
@auth_required(lambda: cv("API_ENABLED_METHODS"))
def webauthn_verify_response(token: str) -> ResponseValue:
form = t.cast(
WebAuthnSigninResponseForm, build_form_from_request("wan_signin_response_form")
)
expired, invalid, state = check_and_get_token_status(
token, "wan", get_within_delta("WAN_SIGNIN_WITHIN")
)
...
form.challenge = state["challenge"]
form.user_verification = state["user_verification"]
form.is_secondary = False
form.is_verify = True
if form.validate_on_submit():
# update last use and sign count
after_this_request(view_commit)
assert form.cred
assert form.user
form.cred.lastuse_datetime = _security.datetime_factory()
form.cred.sign_count = form.authentication_verification.new_sign_count
_datastore.put(form.cred)
# verified - so set freshness time.
session["fs_paa"] = time.time()
...
flask_security/webauthn.py:276-308 (the form's validate()):
def validate(self, **kwargs: t.Any) -> bool:
if not super().validate(**kwargs):
return False # pragma: no cover
...
try:
auth_cred = parse_authentication_credential_json(self.credential.data)
except (...):
...
return False
# Look up credential Id (raw_id) and user. 7.2.6/7
self.cred = _datastore.find_webauthn(credential_id=auth_cred.raw_id)
...
# This shouldn't be able to happen if datastore properly cascades delete
self.user = _datastore.find_user_from_webauthn(self.cred)
self.user is resolved from the attacker-controlled credential_id and is never compared to current_user. The state token issued by _signin_common (webauthn.py:589-622) carries only {challenge, user_verification}, so state tokens are not bound to any user and replay portably across sessions:
def _signin_common(user: UserMixin | None, usage: list[str]) -> tuple[t.Any, str]:
...
state = {
"challenge": challenge,
"user_verification": uv,
}
...
state_token = t.cast(str, _security.wan_serializer.dumps(state))
return o_json, state_token
Contrast with the patch in oauth_glue.py:211 that GHSA-97r5-pg8x-p63p shipped:
next_loc = session.pop("fs_oauth_next", None)
if user and user.email == current_user.email:
# verified - so set freshness time.
session["fs_paa"] = time.time()
That user.email == current_user.email clamp is the missing check on the WebAuthn side.
How input reaches the sink
- Attacker logs in to their own account and registers their own WebAuthn
credential (call it
cred_attacker). They retain a copy of any validnavigator.credentials.get()assertion JSON produced by their authenticator (one signature is enough; can also be produced fresh on demand per request). - Attacker holds, or gets, a victim session in a state where
fs_paais pastFRESHNESS. The victim is authenticated as themselves; the gate stops them from invoking freshness-protected business endpoints (/change,/change-username,/wf-add,/us-setup, anything decorated with@auth_required(within=...)). - The victim session calls
POST /wan-verifyand receives awan_statetoken. The state token has no user binding. - Attacker submits an assertion that proves possession of
cred_attacker, inside the victim session, toPOST /wan-verify/<wan_state>. WebAuthnSigninResponseForm.validateresolvesform.userto the attacker account fromfind_user_from_webauthn(self.cred), signs/verifies the assertion against the (attacker-controlled) public key it stored at registration time, and returnsTrue. The user-handle check onauth_cred.response.user_handle(if present) compares againstself.user.fs_webauthn_user_handle, i.e. it compares attacker user-handle to attacker user, so it passes trivially.webauthn_verify_responsethen writessession["fs_paa"] = time.time(). The session user is unchanged (still the victim) but the freshness clock is reset by a cryptographic proof of the attacker's authenticator.- Any subsequent
@auth_required(within=...)endpoint now succeeds inside the victim session.
End-to-end reproduction
Reproduction is an in-process Flask test client driving the published wheel (pip install Flask-Security-Too==5.8.0, also re-run against 5.8.1 since GHSA-97r5-pg8x-p63p's fix shipped with that release only touched oauth_glue.py). The full transcript is in the Proof of concept section below; here is the boot recipe:
python3.12 -m venv venv
source venv/bin/activate
pip install --quiet 'Flask-Security-Too==5.8.0' Flask-SQLAlchemy webauthn email-validator argon2_cffi
python poc.py
Captured run-time output (5.8.0 path):
=== Submit BOB's WebAuthn assertion to Alice's /wan-verify-response ===
cross-user assertion status: 200
alice fs_uniquifier in session AFTER: '408245d132bc4213a55606c46f40e038' # still Alice
fs_paa BEFORE: 1779582282.550872
fs_paa AFTER : 1779585882.615287 # advanced
=== Demonstrate impact: /sensitive (freshness-protected) accepted ===
/sensitive after cross-user verify status: 200
Re-run against 5.8.1 produces the same 200 on the cross-user assertion and the same 200 on the freshness-gated endpoint, confirming that the patch for GHSA-97r5-pg8x-p63p did not extend to the WebAuthn path.
Proof of concept
Mocked WebAuthn fixtures (REG_DATA_UV, SIGNIN_DATA_UV, REG_DATA1
Which versions are affected by GHSA-f66q-9rf6-8795?
2 open source packages ship an affected version, including Flask-Security-Too and flask-security-too.
| Package | Affected | Fixed in |
|---|---|---|
Flask-Security-Too PyPI | From 5.8.0 to 5.8.1 | None |
flask-security-too PyPI | From 5.8.0 to 5.8.1 | None |
How do I fix GHSA-f66q-9rf6-8795?
No fix has been published for GHSA-f66q-9rf6-8795 yet. Limit exposure of the affected software until one is available.
Upgrade, and rotate credentials and sessions that may have been exposed. More about Authentication bypass.
Is GHSA-f66q-9rf6-8795 being exploited?
There is no known exploitation of GHSA-f66q-9rf6-8795 in the wild and no public exploit code.