Skip to content

fix(ui): resolve login redirect loop when reverse proxy adds HttpOnly to cookies#23532

Merged
krrish-berri-2 merged 32 commits into
BerriAI:litellm_oss_staging_04_04_2026from
jaxhend:litellm_login_httponly_cookie_fix
Apr 6, 2026
Merged

fix(ui): resolve login redirect loop when reverse proxy adds HttpOnly to cookies#23532
krrish-berri-2 merged 32 commits into
BerriAI:litellm_oss_staging_04_04_2026from
jaxhend:litellm_login_httponly_cookie_fix

Conversation

@jaxhend

@jaxhend jaxhend commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #19663

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Type

🐛 Bug Fix

Changes

When LiteLLM is behind nginx-ingress (or similar) with security-hardened configs, the reverse proxy adds HttpOnly to all upstream Set-Cookie headers. This makes the JWT token unreadable by JavaScript (document.cookie returns null for HttpOnly cookies), causing an infinite login redirect loop.

Fix: Return the JWT token in the /v2/login response body alongside the existing cookie. The UI reads the token from the response body and sets it via document.cookie (which creates a JS-accessible, non-HttpOnly cookie), making auth resilient to proxy-added cookie flags.

Files changed

  • litellm/proxy/proxy_server.py: Add token field to /v2/login JSON response body
  • ui/.../utils/cookieUtils.ts: New setTokenCookie() utility that sets a JS-accessible token cookie
  • ui/.../components/networking.tsx: loginCall() reads token from response body and calls setTokenCookie()
  • tests/test_litellm/proxy/test_proxy_server.py: Updated assertion to expect token in response
  • ui/.../utils/cookieUtils.test.ts: Added tests for setTokenCookie (set, overwrite, SSR safety)
  • ui/.../components/networking.test.ts: Added setTokenCookie to cookieUtils mock

Backward compatibility

  • Old frontend + new backend: Extra token field in response body is ignored. Cookie still works.
  • New frontend + old backend: data.token is undefined, if (data.token) is falsy, falls back to server-set cookie.
  • No proxy adding HttpOnly: Both server cookie and JS-set cookie coexist with same value. No conflict.

🤖 Generated with Claude Code

… 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]>
@vercel

vercel Bot commented Mar 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 31, 2026 7:51pm

Request Review

@jaxhend

jaxhend commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 HttpOnly to all upstream Set-Cookie headers. Because HttpOnly cookies are invisible to document.cookie, the frontend could never read the JWT token, causing a perpetual re-login loop.

Approach: The backend (/v2/login) now includes the JWT in the JSON response body alongside the existing Set-Cookie header. The frontend calls the new storeLoginToken() utility, which uses two complementary strategies:

  1. JS-accessible cookie at path=/ui — a different path from the server-set path=/ cookie, so RFC 6265's restriction (JS cannot overwrite an HttpOnly cookie with the same name/domain/path) does not apply.
  2. sessionStorage as a secondary fallback — for environments where even the JS-set cookie is blocked.

All previous review concerns from this PR's extensive discussion thread have been addressed.

Confidence Score: 5/5

Safe 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.

Important Files Changed

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

Comment thread litellm/proxy/proxy_server.py
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts Outdated
- 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]>
Comment thread ui/litellm-dashboard/src/components/networking.test.ts
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts Outdated
@jaxhend

jaxhend commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

@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]>
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts Outdated
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts Outdated
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts Outdated
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts Outdated
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]>
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts
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]>
@jaxhend

jaxhend commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 523d1c3 — all three issues from the latest Greptile review:

  1. page.tsx local getCookie bypasses sessionStorage fallback — removed the local getCookie, now imports the shared one from cookieUtils.ts which has the sessionStorage fallback.
  2. user_dashboard.tsx local getCookie — same fix, removed local copy and imported shared getCookie.
  3. deleteCookie in page.tsx doesn't clear sessionStorage — now calls sessionStorage.removeItem(name) alongside the cookie clear.
  4. sessionStorage fallback not scoped to "token" key — fallback now guarded with name === "token", with a test verifying non-token keys return null.

Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts
Comment thread ui/litellm-dashboard/src/app/page.tsx
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]>
Comment thread ui/litellm-dashboard/src/utils/cookieUtils.ts Outdated
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
jaxhend and others added 7 commits March 31, 2026 22:39
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]>
@gitguardian

gitguardian Bot commented Mar 31, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secrets in your pull request
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
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. 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


🦉 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.

@krrish-berri-2 krrish-berri-2 changed the base branch from main to litellm_oss_staging_04_04_2026 April 6, 2026 18:12
@krrish-berri-2 krrish-berri-2 merged commit f233520 into BerriAI:litellm_oss_staging_04_04_2026 Apr 6, 2026
55 of 60 checks passed
@jaxhend jaxhend deleted the litellm_login_httponly_cookie_fix branch April 6, 2026 19:19
yuneng-berri added a commit that referenced this pull request May 31, 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.
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
… 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>
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 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 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Incorrect redirect URLs when LiteLLM is deployed in a distributed environment (e.g k8s)

2 participants