Skip to main content
CVE-2026-115910VulnerabilityPublished

social-auth-core · Python Social Auth: OpenID Connect nonce is not bound to the browser session, so a stolen authorization code logs the

Affects social-auth-coreSource GCVE Database

0
Authoritative record:CVE-2026-115910· Medium 6.8

Not known to be exploited · CVSS 6.8 Medium : upgrade to 6.2.0 when convenient.

Data as of 28 Oct 2026, 22:12 UTC

CVSS 3.1 base score
6.8Medium
How severe the flaw is on the 0–10 CVSS scale.
Patch
Available – fixed in 6.2.0
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N

Exploitation evidence

  1. Disclosed: evidence found.

    CVE published 26 Oct 2026

  2. Detectable: no evidence on record.

    No public nuclei template indexed

  3. Weaponised: no evidence on record.

    No public exploit code indexed in Metasploit or Exploit-DB

  4. Exploited in the wild: no evidence on record.

    Not in CISA KEV and no source reports exploitation

  5. Observed by OffSeq Mirage: no evidence on record.

    No attack traffic recorded by OffSeq Mirage sensors

A missing step is not evidence of safety. Sources: cve.org (opens in a new tab), the Metasploit, Exploit-DB and nuclei-templates index as of 26 Oct 2026.

What to do

Upgrade social-auth-core to 6.2.0.

Affected software

ProductAffectedFixed in
PyPIsocial-auth-coreAffected
  • <6.2.0
Fixed in
  • 6.2.0

Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.

Description

### Summary The generic OpenID Connect backend (`OpenIdConnectAuth` and every backend built on it) stores the `nonce` in the shared Association table, not in the user's session. When the ID token comes back, `validate_claims` only checks that the nonce exists in that table and is not expired. It does not check that the nonce was issued to the browser that is finishing the login. So the nonce does not do its job. OIDC Core (section 15.5.2) and RFC 9700 (section 4.5) use the nonce to stop authorization code injection: an attacker who gets hold of a victim's unused authorization code should not be able to redeem it in the attacker's own browser. With social-auth-core, the attacker can. They start their own login to get a valid `state` for their session, then hit the callback with the victim's `code` and their own `state`. `state` passes because it belongs to the attacker's session, and the nonce in the victim's ID token passes because it is in the global table. The attacker's browser is now logged in as the victim. PKCE would block this, but `OpenIdConnectAuth` sets `DEFAULT_USE_PKCE = False` (the OAuth2 base class defaults to `True`), so the nonce is the only binding in the default setup. ### Tested versions social-auth-core 6.1.1 (latest), 6.1.0 and 5.2.0, with social-auth-app-django 7.1.0 and Django 6.1.2, Python 3.14. The same code is on master (098ef399c1). ### Details `social_core/backends/open_id_connect.py` in 6.1.1: - line 252: `auth_params` calls `get_and_store_nonce(self.authorization_url(), state)` - lines 333 to 346: `get_and_store_nonce` stores the nonce as an `OpenIdConnectAssociation` keyed by `server_url` and `handle=nonce`. The state goes into `assoc_type` but is never read back. - lines 348 to 358: `get_nonce` looks the nonce up by `server_url` and `handle` only - lines 393 to 415: `validate_claims` accepts any stored, unexpired nonce and removes it - line 110: `DEFAULT_USE_PKCE = False` ### PoC Everything runs locally. `mockidp.py` is a small OpenID provider on 127.0.0.1 that checks `redirect_uri` and enforces PKCE when a `code_challenge` was sent, like a normal provider. `social_nonce.py` uses two Django test clients as two browsers. ``` pip install social-auth-core social-auth-app-django django pyjwt cryptography python social_nonce.py ``` Output on 6.1.1: ``` [inject] attacker session completes with victim code: (200, 'user=victim') ``` Controls: ``` MODE=control_victim python social_nonce.py [control] victim completes normally: (200, 'user=victim') MODE=control_unknown_nonce python social_nonce.py [control] unknown nonce: ('EXC', 'AuthResponseError: The authentication response could not be matched to the request.') PKCE=1 python social_nonce.py [inject] attacker session completes with victim code: ('EXC', 'AuthCredentialError: The authorization code was rejected. Please restart login.') ``` So the nonce check runs, but it accepts a nonce that belongs to another browser. With PKCE on, the same attack fails. mockidp.py: ```python """Minimal local OpenID Provider on 127.0.0.1 for testing RP libraries.""" import json, time, secrets, threading, hashlib, base64, urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import jwt from cryptography.hazmat.primitives.asymmetric import rsa def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() class IdP: def __init__(self, port=0, client_id="rp-client", client_secret="rp-secret", name="idp"): self.key = rsa.generate_private_key(public_exponent=65537, key_size=2048) self.kid = name + "-k1" self.client_id, self.client_secret = client_id, client_secret self.codes = {} self.access = {} self.claims_override = {} self.srv = ThreadingHTTPServer(("127.0.0.1", port), self._handler()) self.port = self.srv.server_address[1] self.issuer = f"http://127.0.0.1:{self.port}" threading.Thread(target=self.srv.serve_forever, daemon=True).start() def jwk(self): n = self.key.public_key().public_numbers() tb = lambda i: i.to_bytes((i.bit_length() + 7) // 8, "big") return {"kty": "RSA", "kid": self.kid, "use": "sig", "alg": "RS256", "n": b64u(tb(n.n)), "e": b64u(tb(n.e))} def discovery(self): i = self.issuer return {"issuer": i, "authorization_endpoint": i + "/authorize", "token_endpoint": i + "/token", "userinfo_endpoint": i + "/userinfo", "jwks_uri": i + "/jwks", "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], "code_challenge_methods_supported": ["S256"]} def user_authorizes(self, authorize_url, sub): """The end user `sub` approves an authorization request; returns (code, params).""" q = dict(urllib.parse.parse_

References

Community reviews

Loading reviews…