# 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.

- **Severity:** Not scored
- **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 available:** No
- **Published:** 7 Jul 2026

## 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`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/flask_security/webauthn.py#L846-L889) (commit
`5c44c76e33a20b67d02115e26d2da4bab18c094e`):

```python
@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`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/flask_security/webauthn.py#L276-L308) (the form's `validate()`):

```python
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`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/webauthn.py#L589-L622)) carries only `{challenge, user_verification}`, so state tokens are not bound to any user and replay portably across sessions:

```python
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`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/oauth_glue.py#L211) that `GHSA-97r5-pg8x-p63p` shipped:

```python
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

1. Attacker logs in to their own account and registers their own WebAuthn
   credential (call it `cred_attacker`). They retain a copy of any valid
   `navigator.credentials.get()` assertion JSON produced by their authenticator
   (one signature is enough; can also be produced fresh on demand per request).
2. Attacker holds, or gets, a victim session in a state where `fs_paa` is past
   `FRESHNESS`. 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=...)`).
3. The victim session calls `POST /wan-verify` and receives a `wan_state`
   token. The state token has no user binding.
4. Attacker submits an assertion that proves possession of `cred_attacker`,
   inside the victim session, to `POST /wan-verify/<wan_state>`.
5. `WebAuthnSigninResponseForm.validate` resolves `form.user` to the attacker
   account from `find_user_from_webauthn(self.cred)`, signs/verifies the
   assertion against the (attacker-controlled) public key it stored at
   registration time, and returns `True`. The user-handle check on
   `auth_cred.response.user_handle` (if present) compares against
   `self.user.fs_webauthn_user_handle`, i.e. it compares attacker user-handle
   to attacker user, so it passes trivially.
6. `webauthn_verify_response` then writes `session["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.
7. 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:

```bash
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 | Ecosystem | 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.

## 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.

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-f66q-9rf6-8795 and fix it if it is affected.

What it is: 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.
Exploitation: There is no known exploitation of GHSA-f66q-9rf6-8795 in the wild and no public exploit code.

Affected packages and fixed versions:
- Flask-Security-Too (PyPI): affected from 5.8.0 up to 5.8.1; no fix listed
- flask-security-too (PyPI): affected from 5.8.0 up to 5.8.1; no fix listed

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-f66q-9rf6-8795/ (Markdown: https://insights.sbomify.com/GHSA-f66q-9rf6-8795/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-f66q-9rf6-8795

---
From sbomify Insights: https://insights.sbomify.com/GHSA-f66q-9rf6-8795/
Summaries are generated and may contain mistakes; the official description above is the record.
