The Socket.IO handshake and the terminal WebSocket route each reimplement JWT authentication instead of going through the HTTP dependency chain. Both verified that the token decoded, that it had not been revoked, and that the user row existed, but neither applied the role check that `get_verified_user` enforces on every HTTP route, so any role outside `user` and `admin` was accepted.
That splits authorization across two planes. Deactivating an account by setting its role to `pending` takes effect immediately over HTTP, which returns 401, while the same JWT still opens a WebSocket. Changing a role disconnects the account's live sockets but does not revoke its token, so the client simply reconnects and gets a fresh session. Until the token expires, four weeks by default, a deactivated account keeps its channel rooms and can still read and write any note it holds an access grant on through the collaborative document handlers.
Resolve the user once, in `get_verified_user_by_token`, and route both WebSocket entry points through it. The role set moves into `VERIFIED_USER_ROLES` so the HTTP and WebSocket gates cannot drift apart, which is the underlying cause rather than either call site on its own. This also replaces five copies of the decode, revocation check and user lookup sequence.
`user-join` now resolves the user instead of reusing the identity cached in `SESSION_POOL`, which costs one extra query per handshake. Gating on the cached role would make the authorization decision depend on every future role-mutation path remembering to tear down the session pool, and that is precisely the invariant that failed here.
With audit logging enabled, every audited request authenticated twice. The route dependency resolved the user once, and then _log_audit_entry called get_current_user again in the request's finally block: a second JWT decode, two more Redis revocation lookups, a second user row fetch with pydantic validation and, crucially, a second fire-and-forget last-active write transaction per request.
get_current_user now stashes the resolved user on the scope-backed request state (the same mechanism the auth middleware already uses for request.state.token), and the audit middleware reuses it, falling back to the old resolution only when no user was stashed (e.g. routes without an auth dependency). While in the file, the audit path patterns are compiled once in the constructor instead of per request, and the always-log endpoint set is a class attribute instead of a per-call literal; both are fixed for the process lifetime.
Benchmark:
| metric | before | after |
| --- | --- | --- |
| audit auth resolution, CPU floor (JWT decode + user validate only) | 16.7 us | 0.24 us |
| extra work per audited request | 2 Redis GETs + 1 user SELECT + 1 last-active write | none |
The before column understates the saving: it excludes the Redis and DB round trips listed in the second row, which dominate in real deployments.
Functionally verified with a stacked ASGI harness: when the route resolves a user the audit entry carries that user and the auth pipeline is not invoked again; without a stashed user the fallback path still resolves and logs correctly; the skip matrix (exclusions, whitelist mode, always-log auth endpoints, unauthenticated and non-audited methods) is unchanged.
Starlette reconstructs request.url.path from the HTTP Host header without
validation. An attacker can inject a path into the Host header to make
request.url.path return a different value than the path Starlette routes on.
The API key endpoint restriction check was using request.url.path to decide
whether to allow or deny access — making it bypassable via a crafted Host
header on any Starlette version prior to 1.0.1.
Fix: replace request.url.path with request.scope["path"], which reads the
raw ASGI scope path that Starlette uses for routing. This value is set by
the ASGI server from the actual request path and cannot be injected via
HTTP headers, making it safe regardless of Starlette version.
Affected code path:
get_current_user_by_api_key() in backend/open_webui/utils/auth.py
(only triggered when ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS is enabled)
References:
CVE-2026-48710 / BadHost
https://arstechnica.com/information-technology/2026/05/millions-of-ai-agents-imperiled-by-critical-vulnerability-in-open-source-package/
The APIKeyRestrictionMiddleware only inspected the Authorization header for sk- tokens, but get_current_user also reads API keys from cookies and x-api-key headers. This allowed complete bypass of endpoint restrictions by sending the key via an alternate transport.
Moves the restriction check into get_current_user_by_api_key so it runs regardless of how the API key was delivered. Removes the now-redundant middleware.
fix: release database connections immediately after auth instead of holding during LLM calls
Authentication was using Depends(get_session) which holds a database connection
for the entire request lifecycle. For chat completions, this meant connections
were held for 30-60 seconds while waiting for LLM responses, despite only needing
the connection for ~50ms of actual database work.
With a default pool of 15 connections, this limited concurrent chat users to ~15
before pool exhaustion and timeout errors:
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30.00
The fix removes Depends(get_session) from get_current_user. Each database
operation now manages its own short-lived session internally:
BEFORE: One session held for entire request
──────────────────────────────────────────────────
│ auth │ queries │ LLM wait (30s) │ save │
│ CONNECTION HELD ENTIRE TIME │
──────────────────────────────────────────────────
AFTER: Short-lived sessions, released immediately
┌──────┐ ┌───────┐ ┌──────┐
│ auth │ │ query │ LLM (30s) │ save │
│ 10ms │ │ 20ms │ NO CONNECTION │ 20ms │
└──────┘ └───────┘ └──────┘
This is safe because:
- User model has no lazy-loaded relationships (all simple columns)
- Pydantic conversion (UserModel.model_validate) happens while session is open
- Returned object is pure Pydantic with no SQLAlchemy ties
Combined with the telemetry efficiency fix, this resolves connection pool
exhaustion for high-concurrency deployments, particularly on network-attached
databases like AWS Aurora where connection hold time is more impactful.