fix(ui): resolve login redirect loop when reverse proxy adds HttpOnly to cookies#23532
Conversation
… to cookies When LiteLLM is behind nginx-ingress or similar with security-hardened configs, the reverse proxy adds HttpOnly to all Set-Cookie headers. This makes the JWT token unreadable by JavaScript, causing an infinite login redirect loop. Fix by returning the JWT token in the /v2/login response body so the frontend can set a JS-accessible cookie directly. Fixes BerriAI#19663 Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR fixes an infinite login redirect loop that occurs when LiteLLM is deployed behind a security-hardened reverse proxy (e.g. nginx-ingress) that appends Approach: The backend (
All previous review concerns from this PR's extensive discussion thread have been addressed. Confidence Score: 5/5Safe to merge — the fix is well-designed, backward compatible, and all previous review concerns have been addressed. All prior P0/P1 concerns have been resolved across multiple commit iterations. The path-differentiation trick (/ui vs /) is valid per RFC 6265, tests are comprehensive and not weakened, and backward compatibility is preserved in all three deployment scenarios described in the PR. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/proxy/proxy_server.py | Adds token field to /v2/login JSON response body with a clear comment explaining the intentional reverse-proxy HttpOnly workaround decision. |
| ui/litellm-dashboard/src/utils/cookieUtils.ts | New storeLoginToken() sets a JS-accessible cookie at /ui path (avoiding HttpOnly collision) + sessionStorage fallback; getCookie() falls back to sessionStorage for token; clearTokenCookies() now handles sessionStorage cleanup too. |
| ui/litellm-dashboard/src/components/networking.tsx | Replaces two bare document.cookie assignments with storeLoginToken() calls in both v2/v3 login paths. |
| ui/litellm-dashboard/src/app/page.tsx | Removes inline getCookie() (now imported from cookieUtils); deleteCookie(token) delegates cleanup to clearTokenCookies(). |
| ui/litellm-dashboard/src/components/user_dashboard.tsx | Removes debug-log-bearing inline getCookie(); beforeunload handler now preserves the sessionStorage token across page refreshes. |
| ui/litellm-dashboard/src/utils/cookieUtils.test.ts | Adds comprehensive tests for storeLoginToken and getCookie including sessionStorage fallback, SSR guards, and non-token scoping. |
| ui/litellm-dashboard/src/components/networking.test.ts | Adds storeLoginToken to mock registry and two integration tests verifying loginCall behavior. |
Reviews (27): Last reviewed commit: "Merge branch 'BerriAI:main' into litellm..." | Re-trigger Greptile
- Add window guard to setTokenCookie for SSR consistency with clearTokenCookies - Add SSR test for window undefined case - Add code comment explaining why JWT is included in response body Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
@yuneng-jiang Ready for review — Greptile scored 4/5 and both inline comments have been addressed (window guard + JWT exposure comment). All CI failures are pre-existing on main (verified lint and test workflows). Thanks! |
- Add loginCall integration tests verifying setTokenCookie is called with token and skipped when absent (backward-compatibility path) - Use encodeURIComponent/decodeURIComponent in setTokenCookie/getCookie for defense-in-depth against non-standard token formats Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Replace setTokenCookie (which is a no-op when reverse proxy adds HttpOnly)
with storeLoginToken using sessionStorage. Add sessionStorage fallback to
getCookie so the token is found even when the cookie is HttpOnly. Also handle
'=' in cookie values with .slice(1).join("=") and clear sessionStorage on
logout.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace local getCookie functions in page.tsx and user_dashboard.tsx with the shared one from cookieUtils that has the sessionStorage fallback. Without this, the HttpOnly cookie fix was incomplete — page.tsx (the dashboard entry point) could not read the token, causing the redirect loop to persist. Also scope the sessionStorage fallback to the "token" key only, and clear sessionStorage in page.tsx deleteCookie. Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
Addressed in 523d1c3 — all three issues from the latest Greptile review:
|
Also document the sessionStorage cross-tab trade-off: per-tab scope means users behind an HttpOnly proxy must log in once per tab, but this is intentional to avoid localStorage XSS exposure. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Reset files not related to the login cookie fix back to main: - prometheus.py, bedrock converse, guardrail handler - auth_checks.py, reset_budget_job.py, audit_logs.py - test_user_api_key_auth.py Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…nch" This reverts commit 0684a1e.
…s include" This reverts commit 866405f.
This reverts commit 086c416.
This reverts commit b2c3334.
This reverts commit 2905d47.
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 29203054 | Triggered | Generic High Entropy Secret | eb1a83e | tests/test_litellm/test_secret_redaction.py | View secret |
| 29203056 | Triggered | Generic High Entropy Secret | eb1a83e | tests/test_litellm/test_secret_redaction.py | View secret |
| 29375658 | Triggered | JSON Web Token | 3298858 | tests/test_litellm/proxy/auth/test_handle_jwt.py | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
f233520
into
BerriAI:litellm_oss_staging_04_04_2026
When the user has visited both the dev UI (e.g. localhost:3000) and the
proxy UI (e.g. localhost:4000) in the same tab, logging out from the dev
origin produced an infinite logout/login redirect.
The proxy-side LoginPage's "is the user still authenticated?" check
was reading getCookie("token"), which falls back to sessionStorage when
document.cookie has no token. The cross-origin clearTokenCookies() call
from the dev origin can clear cookies on the shared hostname, but cannot
reach sessionStorage on the proxy origin (sessionStorage is per-origin),
so the fallback returned a stale token and LoginPage interpreted the
user as logged in, redirecting back to the dev origin. Dev origin then
saw no cookie and redirected to LoginPage, repeating ~20x per second.
This change introduces getCookieFromDocument(), a cookie-only read with
no sessionStorage fallback, and uses it in LoginPage's already-logged-in
check. The HttpOnly-reverse-proxy defense from PR #23532 is unaffected:
storeLoginToken still writes both the JS cookie at /ui and the
sessionStorage backup, and getCookie still falls back for callers that
want the full read path.
… to cookies (BerriAI#23532) * fix(ui): resolve login redirect loop when reverse proxy adds HttpOnly to cookies When LiteLLM is behind nginx-ingress or similar with security-hardened configs, the reverse proxy adds HttpOnly to all Set-Cookie headers. This makes the JWT token unreadable by JavaScript, causing an infinite login redirect loop. Fix by returning the JWT token in the /v2/login response body so the frontend can set a JS-accessible cookie directly. Fixes BerriAI#19663 * fix: address Greptile review feedback - Add window guard to setTokenCookie for SSR consistency with clearTokenCookies - Add SSR test for window undefined case - Add code comment explaining why JWT is included in response body * fix: address second round of Greptile review feedback - Add loginCall integration tests verifying setTokenCookie is called with token and skipped when absent (backward-compatibility path) - Use encodeURIComponent/decodeURIComponent in setTokenCookie/getCookie for defense-in-depth against non-standard token formats * Update ui/litellm-dashboard/src/utils/cookieUtils.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update ui/litellm-dashboard/src/utils/cookieUtils.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(ui): use sessionStorage instead of cookie for login token storage Replace setTokenCookie (which is a no-op when reverse proxy adds HttpOnly) with storeLoginToken using sessionStorage. Add sessionStorage fallback to getCookie so the token is found even when the cookie is HttpOnly. Also handle '=' in cookie values with .slice(1).join("=") and clear sessionStorage on logout. * fix(ui): use shared getCookie in page.tsx and user_dashboard.tsx Replace local getCookie functions in page.tsx and user_dashboard.tsx with the shared one from cookieUtils that has the sessionStorage fallback. Without this, the HttpOnly cookie fix was incomplete — page.tsx (the dashboard entry point) could not read the token, causing the redirect loop to persist. Also scope the sessionStorage fallback to the "token" key only, and clear sessionStorage in page.tsx deleteCookie. * fix(ui): scope deleteCookie sessionStorage cleanup to token key only Also document the sessionStorage cross-tab trade-off: per-tab scope means users behind an HttpOnly proxy must log in once per tab, but this is intentional to avoid localStorage XSS exposure. * Update ui/litellm-dashboard/src/utils/cookieUtils.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * style: remove stray double blank line in user_dashboard.tsx * fix(ui): guard storeLoginToken against empty/whitespace-only tokens * fix(ui): preserve sessionStorage token across beforeunload clear The existing beforeunload handler calls sessionStorage.clear() to flush cached UI data on page refresh. This also wiped the token stored by storeLoginToken, re-introducing the redirect loop after any page refresh in the HttpOnly proxy scenario. Now the token is saved and restored across the clear. * fix(ui): set JS-accessible cookie at /ui path as HttpOnly workaround sessionStorage alone is unreliable. Also set the token via document.cookie at path=/ui — nginx only adds HttpOnly to server-set Set-Cookie headers, so a JS-set cookie is always readable. * fix(ui): use dynamic cookie path based on server_root_path Hardcoded path=/ui breaks when LiteLLM is deployed with a custom server_root_path. Now derives the cookie path from serverRootPath so it works at /ui, /myapp/ui, etc. Also reuse clearTokenCookies() in deleteCookie() to avoid duplication. * refactor(ui): remove circular dependency in cookieUtils.ts Derive the UI cookie path from window.location.pathname instead of importing serverRootPath from networking.tsx. This breaks the cookieUtils → networking → cookieUtils cycle that could cause serverRootPath to be undefined under certain bundler configurations. * fix(ui): harden getUiCookiePath regex and add missing tests - Use regex /\/ui(?=\/|$)/ to match "/ui" only as a full path segment, preventing false matches on paths like "/my-ui-tool/login". - Add unit tests for storeLoginToken empty/whitespace guard and cookie-at-/ui-path behavior. * style: fix Black formatting in audit_logs.py * fix CI: formatting, test params, remove token from login JSON * fix: reformat with Black 23.x to match CI * fix: keep token in login JSON body for UI storeLoginToken flow * fix: use storeLoginToken in exchangeLoginCode, add credentials include * revert: remove unrelated changes from HttpOnly cookie fix branch Reset files not related to the login cookie fix back to main: - prometheus.py, bedrock converse, guardrail handler - auth_checks.py, reset_budget_job.py, audit_logs.py - test_user_api_key_auth.py * Revert "revert: remove unrelated changes from HttpOnly cookie fix branch" This reverts commit 0684a1e. * Revert "fix: use storeLoginToken in exchangeLoginCode, add credentials include" This reverts commit 866405f. * Revert "fix: keep token in login JSON body for UI storeLoginToken flow" This reverts commit 086c416. * Revert "fix: reformat with Black 23.x to match CI" This reverts commit b2c3334. * Revert "fix CI: formatting, test params, remove token from login JSON" This reverts commit 2905d47. --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
When the user has visited both the dev UI (e.g. localhost:3000) and the
proxy UI (e.g. localhost:4000) in the same tab, logging out from the dev
origin produced an infinite logout/login redirect.
The proxy-side LoginPage's "is the user still authenticated?" check
was reading getCookie("token"), which falls back to sessionStorage when
document.cookie has no token. The cross-origin clearTokenCookies() call
from the dev origin can clear cookies on the shared hostname, but cannot
reach sessionStorage on the proxy origin (sessionStorage is per-origin),
so the fallback returned a stale token and LoginPage interpreted the
user as logged in, redirecting back to the dev origin. Dev origin then
saw no cookie and redirected to LoginPage, repeating ~20x per second.
This change introduces getCookieFromDocument(), a cookie-only read with
no sessionStorage fallback, and uses it in LoginPage's already-logged-in
check. The HttpOnly-reverse-proxy defense from PR BerriAI#23532 is unaffected:
storeLoginToken still writes both the JS cookie at /ui and the
sessionStorage backup, and getCookie still falls back for callers that
want the full read path.
Relevant issues
Fixes #19663
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewType
🐛 Bug Fix
Changes
When LiteLLM is behind nginx-ingress (or similar) with security-hardened configs, the reverse proxy adds
HttpOnlyto all upstreamSet-Cookieheaders. This makes the JWT token unreadable by JavaScript (document.cookiereturns null for HttpOnly cookies), causing an infinite login redirect loop.Fix: Return the JWT token in the
/v2/loginresponse body alongside the existing cookie. The UI reads the token from the response body and sets it viadocument.cookie(which creates a JS-accessible, non-HttpOnly cookie), making auth resilient to proxy-added cookie flags.Files changed
litellm/proxy/proxy_server.py: Addtokenfield to/v2/loginJSON response bodyui/.../utils/cookieUtils.ts: NewsetTokenCookie()utility that sets a JS-accessible token cookieui/.../components/networking.tsx:loginCall()reads token from response body and callssetTokenCookie()tests/test_litellm/proxy/test_proxy_server.py: Updated assertion to expecttokenin responseui/.../utils/cookieUtils.test.ts: Added tests forsetTokenCookie(set, overwrite, SSR safety)ui/.../components/networking.test.ts: AddedsetTokenCookieto cookieUtils mockBackward compatibility
tokenfield in response body is ignored. Cookie still works.data.tokenis undefined,if (data.token)is falsy, falls back to server-set cookie.🤖 Generated with Claude Code