What's happening
A user clicks logout. The dashboard flickers. The login page flickers. The dashboard appears again. The browser tab freezes. The address bar cycles through URLs faster than the eye can read. After a few seconds Chrome shows ERR_TOO_MANY_REDIRECTS or the tab becomes unresponsive. The only way out is closing the tab, and on reopening the user is still signed in.
The Base44 infinite logout loop is a tug-of-war between an auth-required route guard and an auth-clear handler that finishes before storage state is consistent. The guard reads stale auth state on the next render, decides the user is still authenticated, and redirects back to a protected route. The logout handler fires again, clears partial state, and the guard repeats. The loop only ends when the browser stops following redirects. The fix has three layers — clear every storage key synchronously, redirect to a route that is genuinely public, and gate the guard with a sentinel key that survives the clear and tells the guard to stand down during teardown.
This bug surfaces in client work more often than the login white-screen 405 because logout sits closer to the edges of the SDK contract. The login flow exercises the happy path that the Base44 AI agent generates clean code for. The logout flow exercises an inversion of that path — clear state instead of populate it, navigate to a public route instead of a protected one, defend the guard against a destination it would normally accept. None of those inversions are in the default scaffold, so most generated logout handlers ship broken in subtle ways that only show up on a cold-start production session.
We see this on roughly one in five Base44 engagements. The fix is mechanical once the root cause is named, and it does not require platform support. The only prerequisite is reproducing it in production conditions — dev mode hides the race almost completely.
Why the loop forms — the four root causes
The infinite logout loop is always one of four root causes, sometimes in combination. Diagnosing which one you have determines which of the three fix layers needs the most attention.
Root cause 1 — partial storage clear. The logout handler calls localStorage.removeItem("base44.auth.token") and stops there. The Base44 SDK also writes a user-cache key (base44.auth.user), a refresh-token key (base44.auth.refresh), and a session cookie. The token is gone but the user cache is still populated. On the next render, the guard reads the user cache, sees a non-null value, and treats the user as authenticated. The redirect away from the dashboard never fires because the guard sees no need to fire it — but the protected fetch that follows hits an unauthenticated endpoint, fails, the error handler triggers a logout, and the loop is now between the guard's "you are signed in" verdict and the fetch handler's "you are signed out" verdict.
Root cause 2 — redirect target is itself protected. The logout handler clears auth state cleanly, then calls router.push("/login"). In many Base44 scaffolds, /login is wrapped in the same root layout as /dashboard. The layout runs the auth guard on every render. The guard sees the user is signed out, decides to redirect to... /login. But it is already on /login. So the guard checks the URL against a list of public routes, finds /login is not listed (because the scaffold did not list it), and redirects to /. The root path is protected. The guard redirects to /login. The loop closes.
Root cause 3 — stale React state cached across renders. The auth context is held in a React useState or a Zustand store. The logout handler updates the store, but the guard component has a stale closure over the previous render's auth state. The guard reads the stale value, sees an authenticated user, redirects to the dashboard. The dashboard mounts, the auth context updates, the guard re-runs with the new value, sees an anonymous user, redirects to /login. The loop is between two consecutive renders of the same guard with two different snapshots of auth state.
Root cause 4 — browser cache shows stale authenticated state. The logout handler clears storage and navigates correctly, but the browser serves the destination page from the back-forward cache (bfcache) with the prior page's JavaScript memory still active. The cached page has a populated auth context, the guard re-runs against the cached snapshot, and the redirect chain restarts. This is the rarest cause but the hardest to diagnose because it depends on browser version, page lifecycle event handling, and whether the dashboard registers a pagehide listener (which evicts the page from bfcache).
In our engagement data the breakdown is roughly: partial clear in 50 percent, protected redirect target in 30 percent, stale React state in 15 percent, bfcache in 5 percent. Most teams have a combination — the partial clear surfaces the protected redirect target which surfaces the stale React state.
How to reproduce and confirm
Run these steps in order. Do not move to the fix until at least the first five steps match.
- Build for production and serve locally. Run
npm run buildthennpm run start. Do not reproduce in dev mode. Hot module reload, React Strict Mode, and dev-only delays mask the race. - Open DevTools, Network tab, with Preserve log enabled. Disable cache. Reload the app and sign in fully.
- Click logout and immediately switch focus to the Network tab. The redirect chain will fly past — that is fine. You only need the URL pattern.
- Stop the loop. Click the browser stop button or close the tab. The Network tab keeps the captured requests.
- Read the URL pattern in the captured log. You will see two or three URLs alternating in rapid succession. The exact pair names your two pieces of code in conflict. Most commonly:
/dashboard→/login→/dashboard→/login. - Open Application tab and inspect storage. Look at localStorage, sessionStorage, and Cookies for any key containing
base44,auth,token, orsession. Note which are empty and which still hold values. Partial state is the smoking gun. - Find your logout handler in the codebase. Read the code that runs when the user clicks the logout button. Note what storage it touches and what URL it navigates to.
- Find your auth guard. Most Base44 scaffolds put it in a layout file or a wrapping
AuthGatecomponent. Note what auth state it checks and what it does when the user is anonymous. - Visit the post-logout redirect target directly while logged in. Open a new tab, paste the URL the logout handler navigates to, and watch what happens. If it redirects you, that target is not public and cannot be the logout destination.
- Reproduce in an incognito window with third-party cookies blocked. Cookie-restricted browsers widen every SDK race and surface bugs that pass on default settings. If the loop is worse here, the synchronous clear is missing a cookie or session path.
If steps 5 and 6 confirm the alternating-URL pattern and partial storage state, you have the infinite logout loop and the fix below applies.
The fix — three layers
You need three changes. Skipping any one of them leaves a window where the loop can re-form.
Layer 1: Clean clear of every auth storage key
The logout handler must clear every place the Base44 SDK stores auth state in one synchronous pass, then call the SDK's signOut to reset internal caches.
"use client";
import { base44 } from "@/lib/base44";
// Every storage key the Base44 SDK is known to write. Verify against your
// SDK version — the key prefixes have changed across releases. Grep the
// node_modules/@base44/sdk source for "setItem(" to find the current set.
const BASE44_LOCAL_STORAGE_KEYS = [
"base44.auth.token",
"base44.auth.refresh",
"base44.auth.user",
"base44.auth.expiresAt",
] as const;
const BASE44_SESSION_STORAGE_KEYS = [
"base44.session.id",
"base44.session.csrf",
] as const;
const BASE44_COOKIE_KEYS = ["base44_session", "base44_refresh"] as const;
export async function logoutCleanly(): Promise<void> {
// Step 1: set the sentinel BEFORE clearing anything. The auth guard
// checks for this on every render and stands down while it is present.
// This is what prevents the guard from re-redirecting mid-teardown.
window.sessionStorage.setItem("base44.logout.in-progress", "1");
// Step 2: call the SDK signOut to invalidate the server session and
// reset any in-memory caches the SDK is holding. Awaited — we do not
// navigate until the server has acknowledged the sign-out.
try {
await base44.auth.signOut();
} catch (err) {
// Network failure during signOut should not block the client clear.
// Log it and proceed — the local state must come clean regardless.
console.warn("base44.auth.signOut() failed; clearing client state anyway", err);
}
// Step 3: wipe every known storage location in one synchronous pass.
for (const key of BASE44_LOCAL_STORAGE_KEYS) {
window.localStorage.removeItem(key);
}
for (const key of BASE44_SESSION_STORAGE_KEYS) {
window.sessionStorage.removeItem(key);
}
for (const key of BASE44_COOKIE_KEYS) {
document.cookie = `${key}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;
}
// Step 4: hard-navigate to a verified public route. window.location.href
// is intentional — it discards the current page's JavaScript memory and
// forces a clean load of the destination, bypassing the bfcache race.
// router.push would keep the loop alive on a bfcache restore.
window.location.href = "/goodbye";
}
Three details matter and they are not negotiable. First, the sentinel is set before anything else. If the clear runs first, there is a render window where storage is partially empty and the sentinel is not yet present — the guard will redirect. Second, window.location.href is used instead of router.push. The hard navigation discards the current page's JavaScript memory, which kills the bfcache race and the stale-closure race in one move. Third, the SDK signOut is awaited before storage is touched. Otherwise the SDK may rehydrate state from the server during teardown and re-populate keys you just cleared.
Layer 2: Redirect to a route that is genuinely public
The post-logout destination must have no auth guard. Not "the guard is supposed to skip it." Not "the guard handles the /login case." No guard at all.
The simplest pattern is a dedicated /goodbye route that lives outside any layout that runs the guard.
// app/goodbye/page.tsx
// Lives OUTSIDE the (protected) route group. No layout above it runs
// the auth guard. This is the only safe post-logout destination.
export default function GoodbyePage() {
return (
<main className="goodbye">
<h1>You have been signed out</h1>
<p>Thanks for using the app.</p>
<a href="/login">Sign back in</a>
</main>
);
}
Verify the route is public with one test: open a new browser tab while signed in, paste the full URL, and load it. If the page renders, the route is public. If you are redirected to the dashboard, the guard is still running on it and it cannot be your logout destination. Move the file to a different route segment that is outside the protected layout, then re-test.
This is the layer most teams get wrong. They assume /login is public because the user is unauthenticated there. But many Base44 scaffolds run the guard at the root layout level, which means /login sees the guard too, and the guard's behavior for an anonymous user on a non-listed route is to redirect to / — which is the loop.
Layer 3: Sentinel-aware auth guard
The guard must check for the logout sentinel on every render and short-circuit when it sees one. This is the piece that prevents the loop from re-forming if either of the first two layers has a bug.
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { base44 } from "@/lib/base44";
type AuthState =
| { status: "loading" }
| { status: "logging-out" }
| { status: "authed"; user: { id: string } }
| { status: "anonymous" };
export function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({ status: "loading" });
const router = useRouter();
useEffect(() => {
let cancelled = false;
// Check the sentinel FIRST. If logout is in progress, do nothing —
// do not fetch, do not redirect, do not even resolve the auth state.
// This is the loop-breaker. The hard navigation in logoutCleanly()
// will replace this page entirely; we just need to not redirect
// during the brief window before that navigation lands.
if (
typeof window !== "undefined" &&
window.sessionStorage.getItem("base44.logout.in-progress")
) {
setState({ status: "logging-out" });
return;
}
async function resolve() {
const user = await base44.auth.getCurrentUser().catch(() => null);
if (cancelled) return;
if (user) setState({ status: "authed", user });
else {
setState({ status: "anonymous" });
router.replace("/login");
}
}
resolve();
return () => {
cancelled = true;
};
}, [router]);
if (state.status === "loading" || state.status === "logging-out") {
return <div className="auth-loading" aria-busy="true" />;
}
if (state.status === "anonymous") return null;
return <>{children}</>;
}
The logging-out state is the loop-breaker. When the sentinel is present the guard does not redirect, does not fetch, does not even resolve auth. It renders a minimal teardown view and waits for the hard navigation from Layer 1 to land. By the time the destination page mounts, the sentinel and every other key is gone, and the new page is on a route that has no guard.
The sentinel must live in sessionStorage, not localStorage. SessionStorage is per-tab — if the user opens a new tab during logout, that tab does not see the sentinel and the guard behaves normally there. LocalStorage would persist across tabs and could trap an unrelated tab in the logging-out state forever.
Verification — proving the loop is closed
After shipping all three layers, run this verification before declaring the fix done.
- Run a production build locally with
npm run build && npm run start. - Disable cache in DevTools. Open the Network tab with Preserve log enabled.
- Sign in fully and confirm the dashboard loads.
- Click logout. Watch the Network tab.
- Confirm exactly one navigation event:
/dashboard→/goodbye. Any other URL in the chain is a regression. - Open the Application tab and confirm every
base44.*key is gone from localStorage, sessionStorage, and cookies. The sentinel should also be gone (the hard navigation discarded the session it lived in). - Click "Sign back in" on the goodbye page. The login flow should work normally — if it does not, you have a separate bug (see the login white-screen 405 fix).
- Repeat steps 3-7 ten times. Any repeat redirect or any inconsistency is a regression.
- Repeat the full sequence in an incognito window with third-party cookies blocked. Cookie-restricted browsers widen race windows and surface bugs that pass on default settings.
- Repeat in mobile Safari and Android Chrome. Mobile browsers throttle storage writes and run sync work on deferred microtasks — the verification matters more here than on desktop.
If all ten checks pass, the loop is closed and the fix is durable.
When this isn't your bug — when it's the SDK
If you have shipped all three layers and the loop still forms, the bug is no longer in your code. Two SDK-side cases come up.
The first is when base44.auth.signOut() returns success but the SDK's in-memory token cache is not invalidated. One SDK release had a 5-second cache TTL that was not reset on signOut — for those 5 seconds, getCurrentUser() returned the cached user even though storage was empty, and a fast click triggered the loop from a different angle. The fix was pinning the SDK version until Base44 shipped the cache-invalidation patch.
The second is when third-party auth providers (Google SSO, Apple Sign-In, Median.co wrappers) keep their own session cookies the Base44 SDK does not clear. On the next render the SDK's auth refresh silently re-authenticates against the upstream provider and the user is logged back in. The fix requires calling the provider's signOut endpoint explicitly before the Base44 signOut — see the Google auth fix for the full pattern.
Need this fixed in 48 hours?
Our fix-sprint diagnoses the logout loop on the first call, ships all three fix layers, instruments the logout flow with cold-start telemetry, and verifies zero redirect repeats across 100 production logout sessions before handoff. Fixed price.
Start a fix sprint for the logout loop
Related problems
- Base44 white screen after login — fix the 405 error — the mirror-image bug. Login needs a wait-for-persist, logout needs a wait-for-clear. Both descend from optimistic SDK callbacks.
- Base44 Google auth not working — when an upstream provider keeps a session cookie the Base44 SDK does not clear, logout silently re-authenticates against Google.
- Base44 email verification loop — a sibling redirect-loop bug at a different point in the auth flow; same diagnostic shape, same three-layer fix pattern.