BASE44DEVS

FIX · AUTH · HIGH

Fix Base44 Infinite Loop on Logout — Stop the Redirect Tug-of-War

The Base44 logout loop is a race between an auth-required route guard and an incomplete auth-clear handler. The guard sees the user is still authenticated, redirects to the protected dashboard, the logout handler fires again, clears partial state, and the guard re-redirects on the next render. The loop continues because logout only clears one storage key while the SDK reads from another, or because the post-logout redirect targets a protected route the guard immediately rejects. Fix it by clearing every Base44 auth storage key in one synchronous pass, redirecting to a public route, and gating the guard with a sentinel key that suppresses redirect during teardown. Three layers — clean clear, public-route landing, and sentinel guard — close the loop.

Last verified
2026-05-24
Category
AUTH
Difficulty
MODERATE
DIY possible
YES

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.

  1. Build for production and serve locally. Run npm run build then npm run start. Do not reproduce in dev mode. Hot module reload, React Strict Mode, and dev-only delays mask the race.
  2. Open DevTools, Network tab, with Preserve log enabled. Disable cache. Reload the app and sign in fully.
  3. 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.
  4. Stop the loop. Click the browser stop button or close the tab. The Network tab keeps the captured requests.
  5. 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.
  6. Open Application tab and inspect storage. Look at localStorage, sessionStorage, and Cookies for any key containing base44, auth, token, or session. Note which are empty and which still hold values. Partial state is the smoking gun.
  7. 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.
  8. Find your auth guard. Most Base44 scaffolds put it in a layout file or a wrapping AuthGate component. Note what auth state it checks and what it does when the user is anonymous.
  9. 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.
  10. 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.

  1. Run a production build locally with npm run build && npm run start.
  2. Disable cache in DevTools. Open the Network tab with Preserve log enabled.
  3. Sign in fully and confirm the dashboard loads.
  4. Click logout. Watch the Network tab.
  5. Confirm exactly one navigation event: /dashboard/goodbye. Any other URL in the chain is a regression.
  6. 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).
  7. 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).
  8. Repeat steps 3-7 ten times. Any repeat redirect or any inconsistency is a regression.
  9. 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.
  10. 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

QUERIES

Frequently asked questions

Q.01Why does my Base44 app loop forever when I click logout?
A.01

Your Base44 app loops on logout because two pieces of auth logic are running against each other on every render. The logout handler clears part of the auth state and triggers a navigation. The route guard on the destination page reads what is left of the auth state, decides the user is still signed in (or is signed in again), and redirects back. The next render fires the logout handler or the guard again and the cycle repeats. The loop only ends when the browser hits its redirect limit or the tab is closed. The root cause is almost always one of three things — partial storage clear, a redirect target that is itself protected, or a stale auth-context value cached in React state that the guard reads before the clear has propagated.

Q.02What's the difference between the logout loop and the login white-screen 405?
A.02

Both bugs are SDK-state races, but they fire in opposite directions. The login white-screen 405 happens because the auth token is missing when a protected request goes out — the redirect into the dashboard runs before the token persists. The logout infinite loop happens because the auth token is still present (or partially present) when the guard runs on the next render — the redirect away from the dashboard runs before the storage clear completes. The fix shapes are mirror images. Login needs a wait-for-persist. Logout needs a wait-for-clear. Both need a guard that handles in-flight states explicitly rather than treating every render as a fully-resolved snapshot.

Q.03Why does clearing localStorage with localStorage.clear() not fix it?
A.03

Calling localStorage.clear() is too broad and not always enough. It wipes every key in the origin including non-auth state your app needs, and it does not touch sessionStorage, IndexedDB, cookies, or in-memory SDK caches the Base44 client may be holding. Clearing only localStorage while the SDK still reads from a sessionStorage fallback or an in-memory token cache produces a guard that sees an empty token in one place and a populated token in another. The guard's behavior becomes nondeterministic across renders. The correct clear walks every known Base44 storage location in one synchronous pass, then calls the SDK's official signOut method to reset internal caches, then navigates.

Q.04Why does the loop only happen in production and not in development?
A.04

Development hides the loop because hot module reload, React Strict Mode double-invocation, and dev-only logging give the auth-clear handler enough time to complete between renders. Production runs faster, batches renders more aggressively, and ships fewer side-channel delays. The race window that is invisible in dev is wide open in production. We have measured the same logout flow taking 180ms in dev and 35ms in production on the same hardware — the production path is fast enough to re-enter the guard before the clear has propagated. Always reproduce this bug in a production build with React Strict Mode disabled and DevTools throttling off. Dev mode is not a valid reproduction environment for any SDK-state race in Base44 apps.

Q.05Will redirecting to /login fix the loop?
A.05

Only if /login is genuinely public. In many Base44 scaffolds the /login route is wrapped in the same root layout that runs the auth guard — so a logged-in user landing on /login is redirected to /dashboard, and a partially-logged-out user landing on /login is redirected to /dashboard, which redirects to /login, and the loop closes. Make the redirect target a route that has no guard at all — a marketing page, a dedicated /goodbye route, or the marketing home page on a separate path. Then verify by visiting that route directly while logged in and confirming it does not redirect. If it redirects, it is protected, and it cannot be your post-logout landing target.

Q.06How do I prevent the loop from happening again after I fix it?
A.06

Add a sentinel storage key like base44.logout.in-progress that the logout handler sets before clearing and removes after navigation completes. The guard checks for this key on every render and short-circuits — if logout is in progress, do not redirect, do not fetch protected data, just render a minimal teardown view. The sentinel is the only piece of state that cannot be cleared by the logout handler itself, because the guard depends on it. Combined with the three-layer fix (synchronous clear, public landing, guard short-circuit), the sentinel makes the loop impossible to re-enter. It also gives you an explicit logging hook — every loop attempt is a sentinel-not-removed event, which surfaces regression instantly.

NEXT STEP

Need this fix shipped this week?

Book a free 15-minute call or order a $497 audit. We will respond within one business day.