diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx
index 579e90e530..b300060926 100644
--- a/ui/litellm-dashboard/src/app/page.tsx
+++ b/ui/litellm-dashboard/src/app/page.tsx
@@ -41,8 +41,33 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cx } from "@/lib/cva.config";
function getCookie(name: string) {
- const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "="));
- return cookieValue ? cookieValue.split("=")[1] : null;
+ // Safer cookie read + decoding; handles '=' inside values
+ const match = document.cookie.split("; ").find((row) => row.startsWith(name + "="));
+ if (!match) return null;
+ const value = match.slice(name.length + 1);
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function deleteCookie(name: string, path = "/") {
+ // Best-effort client-side clear (works for non-HttpOnly cookies without Domain)
+ document.cookie = `${name}=; Max-Age=0; Path=${path}`;
+}
+
+function isJwtExpired(token: string): boolean {
+ try {
+ const decoded: any = jwtDecode(token);
+ if (decoded && typeof decoded.exp === "number") {
+ return decoded.exp * 1000 <= Date.now();
+ }
+ return false;
+ } catch {
+ // If we can't decode, treat as invalid/expired
+ return true;
+ }
}
function formatUserRole(userRole: string) {
@@ -149,17 +174,42 @@ export default function CreateKeyPage() {
const redirectToLogin = authLoading === false && token === null && invitation_id === null;
useEffect(() => {
- const token = getCookie("token");
- getUiConfig().then((data) => {
- // get the information for constructing the proxy base url, and then set the token and auth loading
- setToken(token);
- setAuthLoading(false);
- });
+ let cancelled = false;
+
+ (async () => {
+ try {
+ await getUiConfig(); // ensures proxyBaseUrl etc. are ready
+ } catch {
+ // proceed regardless; we still need to decide auth state
+ }
+
+ if (cancelled) return;
+
+ const raw = getCookie("token");
+ const valid = raw && !isJwtExpired(raw) ? raw : null;
+
+ // If token exists but is invalid/expired, clear it so downstream code
+ // doesn't keep trying to use it and cause redirect spasms.
+ if (raw && !valid) {
+ deleteCookie("token", "/");
+ }
+
+ if (!cancelled) {
+ setToken(valid);
+ setAuthLoading(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
}, []);
useEffect(() => {
if (redirectToLogin) {
- window.location.href = (proxyBaseUrl || "") + "/sso/key/generate";
+ // Replace instead of assigning to avoid back-button loops
+ const dest = (proxyBaseUrl || "") + "/sso/key/generate";
+ window.location.replace(dest);
}
}, [redirectToLogin]);
@@ -168,7 +218,23 @@ export default function CreateKeyPage() {
return;
}
- const decoded = jwtDecode(token) as { [key: string]: any };
+ // Defensive: re-check expiry in case cookie changed after mount
+ if (isJwtExpired(token)) {
+ deleteCookie("token", "/");
+ setToken(null);
+ return;
+ }
+
+ let decoded: any = null;
+ try {
+ decoded = jwtDecode(token);
+ } catch {
+ // Malformed token → treat as unauthenticated
+ deleteCookie("token", "/");
+ setToken(null);
+ return;
+ }
+
if (decoded) {
// set accessToken
setAccessToken(decoded.key);
diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
new file mode 100644
index 0000000000..93e15312f6
--- /dev/null
+++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
@@ -0,0 +1,195 @@
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import { vi, describe, it, beforeEach, afterEach, expect } from "vitest";
+
+/** ----------------------------
+ * Hoisted helpers for mocks (required by Vitest)
+ * --------------------------- */
+const { stub, jwtDecodeMock } = vi.hoisted(() => {
+ const React = require("react");
+ const stub = (name: string) => () => React.createElement("div", { "data-testid": name });
+ return {
+ stub,
+ jwtDecodeMock: vi.fn(),
+ };
+});
+
+/** ----------------------------
+ * Mocks
+ * --------------------------- */
+
+// next/navigation: just return empty URLSearchParams (no invitation/page)
+vi.mock("next/navigation", () => ({
+ useSearchParams: () => new URLSearchParams(""),
+}));
+
+// Networking layer
+vi.mock("@/components/networking", () => {
+ return {
+ // Called on mount; we don't care about its contents, only that it resolves
+ getUiConfig: vi.fn().mockResolvedValue({}),
+ // Used to build the redirect URL
+ proxyBaseUrl: "https://example.com",
+ // Called when decoding a valid token
+ setGlobalLitellmHeaderName: vi.fn(),
+ Organization: {},
+ };
+});
+
+// jwt-decode: we’ll swap implementation per test via mockImplementation
+vi.mock("jwt-decode", () => ({
+ jwtDecode: (token: string) => jwtDecodeMock(token),
+}));
+
+// Super-light stubs for all heavy components so rendering doesn't explode
+vi.mock("@/components/navbar", () => ({ default: stub("navbar") }));
+vi.mock("@/components/user_dashboard", () => ({ default: stub("user-dashboard") }));
+vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") }));
+vi.mock("@/components/view_users", () => ({ default: stub("view-users") }));
+vi.mock("@/components/teams", () => ({ default: stub("teams") }));
+vi.mock("@/components/organizations", () => ({
+ default: stub("organizations"),
+ fetchOrganizations: vi.fn(), // consumed in effects
+}));
+vi.mock("@/components/admins", () => ({ default: stub("admin-panel") }));
+vi.mock("@/components/settings", () => ({ default: stub("settings") }));
+vi.mock("@/components/general_settings", () => ({ default: stub("general-settings") }));
+vi.mock("@/components/pass_through_settings", () => ({ default: stub("pass-through-settings") }));
+vi.mock("@/components/budgets/budget_panel", () => ({ default: stub("budget-panel") }));
+vi.mock("@/components/view_logs", () => ({ default: stub("spend-logs") }));
+vi.mock("@/components/model_hub_table", () => ({ default: stub("model-hub-table") }));
+vi.mock("@/components/new_usage", () => ({ default: stub("new-usage") }));
+vi.mock("@/components/api_ref", () => ({ default: stub("api-ref") }));
+vi.mock("@/components/chat_ui/ChatUI", () => ({ default: stub("chat-ui") }));
+vi.mock("@/components/leftnav", () => ({ default: stub("sidebar") }));
+vi.mock("@/components/usage", () => ({ default: stub("usage") }));
+vi.mock("@/components/cache_dashboard", () => ({ default: stub("cache-dashboard") }));
+vi.mock("@/components/guardrails", () => ({ default: stub("guardrails") }));
+vi.mock("@/components/prompts", () => ({ default: stub("prompts") }));
+vi.mock("@/components/transform_request", () => ({ default: stub("transform-request") }));
+vi.mock("@/components/mcp_tools", () => ({ MCPServers: stub("mcp-servers") }));
+vi.mock("@/components/tag_management", () => ({ default: stub("tag-management") }));
+vi.mock("@/components/vector_store_management", () => ({ default: stub("vector-stores") }));
+vi.mock("@/components/ui_theme_settings", () => ({ default: stub("ui-theme-settings") }));
+vi.mock("@/components/organisms/create_key_button", () => ({ fetchUserModels: vi.fn() }));
+vi.mock("@/components/common_components/fetch_teams", () => ({ fetchTeams: vi.fn() }));
+vi.mock("@/components/ui/ui-loading-spinner", () => ({
+ UiLoadingSpinner: stub("spinner"),
+}));
+vi.mock("@/contexts/ThemeContext", () => {
+ const React = require("react");
+ return {
+ ThemeProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
+ };
+});
+vi.mock("@/lib/cva.config", () => ({
+ cx: (...args: string[]) => args.join(" "),
+}));
+
+import CreateKeyPage from "@/app/page";
+
+/** ----------------------------
+ * Helpers
+ * --------------------------- */
+
+function setCookie(raw: string) {
+ // JSDOM allows simple string assignment to document.cookie
+ document.cookie = raw;
+}
+
+function clearAllCookies() {
+ // JSDOM doesn't give an API to clear; overwrite with empty string
+ // plus ensure we wipe known names used by this app.
+ document.cookie = "token=; Max-Age=0; Path=/";
+}
+
+const originalLocation = window.location;
+
+beforeEach(() => {
+ // Fresh module state & DOM
+ vi.clearAllMocks();
+ clearAllCookies();
+
+ // Make location.replace spy-able to validate redirect
+ delete (window as any).location;
+ // minimal location object with replace and assign stubs
+ (window as any).location = {
+ ...originalLocation,
+ href: "http://localhost/",
+ assign: vi.fn(),
+ replace: vi.fn(),
+ };
+});
+
+afterEach(() => {
+ // Restore location to avoid leaking across test envs
+ delete (window as any).location;
+ (window as any).location = originalLocation;
+});
+
+/** ----------------------------
+ * Tests
+ * --------------------------- */
+
+describe("CreateKeyPage auth behavior", () => {
+ it("redirects to SSO when cookie token is expired and clears it (no spasms)", async () => {
+ // Arrange: expired token in cookie
+ setCookie("token=expiredtoken");
+
+ // jwtDecode returns past exp → expired
+ jwtDecodeMock.mockImplementation((tok: string) => {
+ expect(tok).toBe("expiredtoken");
+ return { exp: Math.floor(Date.now() / 1000) - 60 }; // expired 60s ago
+ });
+
+ // Spy on cookie writes to ensure we clear with Max-Age=0
+ const cookieSetSpy = vi.spyOn(document, "cookie", "set");
+
+ // Act
+ render();
+
+ // Assert: we eventually redirect to SSO login (single replace, not assign/href)
+ await waitFor(() => {
+ expect(window.location.replace).toHaveBeenCalledWith("https://example.com/sso/key/generate");
+ });
+
+ // And we attempted to clear the cookie (defensive deletion)
+ const wroteDeletion = cookieSetSpy.mock.calls.some(
+ (args) => typeof args[0] === "string" && args[0].includes("Max-Age=0") && args[0].startsWith("token="),
+ );
+ expect(wroteDeletion).toBe(true);
+ });
+
+ it("does NOT redirect when token is valid and renders the app chrome", async () => {
+ // Arrange: valid token in cookie
+ setCookie("token=validtoken");
+
+ // jwtDecode returns future exp and expected shape
+ jwtDecodeMock.mockImplementation((tok: string) => {
+ expect(tok).toBe("validtoken");
+ return {
+ exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1h in the future
+ key: "accessKey-123",
+ user_role: "app_user",
+ user_email: "user@example.com",
+ login_method: "username_password",
+ premium_user: false,
+ auth_header_name: "x-litellm-auth",
+ user_id: "u_123",
+ };
+ });
+
+ // Act
+ render();
+
+ // Assert: no redirect
+ await waitFor(() => {
+ expect(window.location.replace).not.toHaveBeenCalled();
+ });
+
+ // And some top-level UI appears (Navbar stub)
+ await waitFor(() => {
+ expect(screen.getByTestId("navbar")).toBeInTheDocument();
+ });
+ });
+});