BASE44DEVS

FIX · AUTH · HIGH

Base44 Email Verification Loop Fix — Stop the Verify Cycle

Base44 email verification loops happen when the magic link points at a route that never runs the token-exchange handler, so the user record's verified flag never flips after the click. The browser re-reads stale auth state and the gate re-renders, looking identical to before. Fix it by adding an explicit `/auth/callback` route that calls `exchangeCodeForSession`, writes `email_confirmed_at` on the user record, then calls `refreshSession()` on the auth client before redirecting. Recover stuck users by manually flipping `email_confirmed_at` from the admin panel and forcing a logout to clear the cached JWT. Check that links are not being mangled by email scanners pre-fetching tokens, that the redirect URL matches the allowed list in your auth provider settings, and that Row Level Security on the user table permits the verification handler to write the confirmation timestamp.

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

Why base44 email verification loops

Base44 email verification loops because the link the user clicks does not run the token-exchange handler that flips the email_confirmed_at flag on the user record. The browser navigates, the URL changes, the page reloads — and the frontend re-reads stale auth state, sees no confirmation timestamp, and re-mounts the verify-email gate. The fix is an explicit /auth/callback route that calls exchangeCodeForSession, writes the confirmation timestamp, refreshes the session, and then redirects to the destination. Recover stuck users by manually setting email_confirmed_at = NOW() in the user record. Check that email security scanners are not pre-fetching tokens, that the redirect URL is on the allowed list, and that Row Level Security on the user table permits the handler to write the verification column. For high-value B2B accounts, switch from magic links to 6-digit OTP codes to sidestep most link-based failure modes entirely.

A user signs up. Base44 sends a verification email. The user clicks "Verify my email." The browser opens, flashes a loader, and lands back on the "Please verify your email to continue" screen — the same screen they were on before clicking. They click the link again. Same result. They check spam, request a resend, try a different browser. Nothing flips.

You check the database. The user record has email_confirmed_at: null. You manually flip it from the admin panel. The user logs out, logs back in, gets in. Bug fixed for one user. Tomorrow it happens again.

The loop is one of the most common authentication failure modes on Base44 apps, and it almost always traces to the same root cause: the verification link points to a URL that does not actually run the code that updates the user record. The platform's default scaffolding leaves a gap between "user clicks link" and "user gets marked verified." Until you close that gap with an explicit callback handler, every new user is a coin flip.

What causes the base44 email verification loop

Five distinct failure modes produce identical symptoms. You will likely have more than one in production at once.

1. Verification link points to a route that does not run the token-exchange handler. This is the most common cause. The default Base44 sign-up flow generates a verification link of the form https://yourapp.com/?code=abc123 or https://yourapp.com/verify?token=abc123. The root route or /verify route in your app does not contain the code that exchanges the token for a confirmed session. The frontend mounts, sees no session change, and re-renders the verify-email gate. The token is wasted — single-use, often expires unused.

2. Token-exchange handler runs but does not update the user record's verified flag. The handler successfully exchanges the token for a session, but writes the confirmation to a custom table (or to no table at all), while your frontend reads email_confirmed_at directly from auth.users. The session is technically "logged in" but the gate condition (if (!user.email_confirmed_at) return <VerifyGate />) still returns true.

3. Frontend reads stale auth state and re-renders the verify-email gate. The handler wrote the database row correctly. But the React frontend cached the old JWT in localStorage, and the auth context still holds the pre-verification user object. Without an explicit refreshSession() call, the gate re-mounts using the cached data. The user is verified in the database and unverified in the UI.

4. Email link expires before user clicks. Default expiry is often 15-60 minutes. B2B users frequently check email asynchronously — they may read your verification email three hours later. Expired tokens silently fail and the frontend has no way to distinguish "expired" from "invalid," so it just re-renders the gate.

5. Verification handler succeeds but RLS prevents writing to the user record. Row Level Security on auth.users or your profiles table denies the UPDATE statement when the handler runs as the anon role. The handler logs an error nobody reads. The user record stays unverified. This is the most insidious failure mode because the handler appears to complete successfully — the HTTP response is 200, the redirect happens, no exception bubbles up. Only the database write was silently rejected.

A sixth, less common cause: email security scanners (Outlook Safe Links, Mimecast, Proofpoint, Gmail's URL warmer) pre-fetch the link the moment the email arrives. Tokens are single-use; by the time the human clicks, the token is already consumed and returns already_used, which your frontend interprets as a generic failure.

Sources: Supabase Auth GitHub issues #15234, #21476 (verification callback drift), feedback.base44.com posts on auth flows, Base44 Discord support channel (recurring "stuck on verify" reports from solo founders).

How to confirm base44 email verification loop (reproduction)

A reliable reproduction tells you which of the five causes you are hitting.

  1. Open a fresh incognito window. Sign up with a new email address you control (use Mailosaur, Mailtrap, or a plus-addressed Gmail).
  2. Wait for the verification email. Note the timestamp.
  3. Before clicking, open the email and right-click the verify button. Copy the link address into a text editor. Note the host, the path, and the query parameters. The path is the first clue: if it is / or /verify and you have not written a callback handler at that path, the link is decorative.
  4. Open Base44 dashboard or Supabase SQL editor. Query the user record: SELECT id, email, email_confirmed_at, created_at FROM auth.users WHERE email = 'test@yourdomain.com';. Note email_confirmed_at is null.
  5. In a separate browser tab, open DevTools → Network. Click the verify link. Watch the network panel. Note every request, every redirect, every cookie set.
  6. Look for a request to your token-exchange endpoint (typically POST to /auth/v1/verify or your own /auth/callback). If you see no such request, the link did not trigger an exchange — you have cause #1.
  7. After the click completes and you land back on the verify gate, re-query the user record. Look at email_confirmed_at again.
    • If it is now a timestamp: the database write worked. The frontend is reading stale state — cause #3.
    • If it is still null: the handler did not write — cause #2, #4, or #5.
  8. Check handler logs. In Base44, navigate to Functions → Logs. In Supabase, check Authentication → Logs. Look for entries from the last 60 seconds. An RLS policy error or token expired message tells you which cause you have.
  9. Test the link from a desktop email client (Outlook) and from a mobile email client (Apple Mail) separately. If Outlook fails consistently and Apple Mail works, you have cause #6 (scanner pre-fetch).

Document the cause before writing any fix code. The wrong fix for the wrong cause wastes a day.

How to fix base44 email verification loop — step-by-step

Step 1: Add an explicit /auth/callback route

The root cause for most loops is a missing callback handler. Add a route at /auth/callback that reads the code query parameter and exchanges it for a session.

// app/auth/callback/route.ts (Next.js App Router)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const next = url.searchParams.get("next") ?? "/dashboard";

  if (!code) {
    return NextResponse.redirect(
      new URL("/auth/error?reason=missing_code", request.url)
    );
  }

  const cookieStore = cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get: (n) => cookieStore.get(n)?.value,
        set: (n, v, opts) => cookieStore.set(n, v, opts),
        remove: (n, opts) => cookieStore.set(n, "", opts),
      },
    }
  );

  const { error } = await supabase.auth.exchangeCodeForSession(code);
  if (error) {
    return NextResponse.redirect(
      new URL(`/auth/error?reason=${encodeURIComponent(error.message)}`, request.url)
    );
  }

  return NextResponse.redirect(new URL(next, request.url));
}

Update your auth email template to point at /auth/callback?code={{ .Token }}&next=/dashboard. The Token placeholder syntax depends on your provider — Supabase uses {{ .Token }}, Clerk uses different patterns. Confirm with provider docs.

Step 2: Update the redirect URL allowlist

In Supabase: Authentication → URL Configuration → Redirect URLs. Add https://yourdomain.com/auth/callback and http://localhost:3000/auth/callback for local dev. A redirect to a non-allowed URL silently falls back to the site root, which usually re-renders the verify gate and looks identical to the original bug.

In Base44's native auth, the equivalent setting is under Settings → Authentication → Allowed Callback URLs.

Step 3: Force session refresh after exchange

If you have a client-side callback page instead of a server route, you must explicitly refresh the session after the exchange. The cached JWT still says "unverified" until the next refresh.

// Client-side callback (less preferred, but sometimes necessary)
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { createBrowserClient } from "@supabase/ssr";

export default function AuthCallback() {
  const router = useRouter();

  useEffect(() => {
    const supabase = createBrowserClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    );

    const url = new URL(window.location.href);
    const code = url.searchParams.get("code");
    if (!code) {
      router.replace("/auth/error?reason=missing_code");
      return;
    }

    supabase.auth
      .exchangeCodeForSession(code)
      .then(async ({ error }) => {
        if (error) {
          router.replace(`/auth/error?reason=${encodeURIComponent(error.message)}`);
          return;
        }
        // Critical: refresh the cached session before navigating
        await supabase.auth.refreshSession();
        router.replace("/dashboard");
      });
  }, [router]);

  return <p>Verifying your email…</p>;
}

The refreshSession() call is the line most tutorials skip. Without it, the gate re-renders against stale state.

Step 4: Audit RLS on the user record

If the handler runs but email_confirmed_at stays null, RLS is the most likely culprit. Open Supabase SQL editor:

SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE tablename IN ('users', 'profiles')
ORDER BY tablename, policyname;

If you have a custom profiles table that mirrors auth state, ensure the verification flow can update it. The safest pattern: have the verification handler run with the service-role key (not the anon key) when it needs to write the confirmation column.

// Server-side handler with elevated permissions
import { createClient } from "@supabase/supabase-js";

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // server-only secret, never expose to client
  { auth: { autoRefreshToken: false, persistSession: false } }
);

// After exchangeCodeForSession succeeds:
await supabaseAdmin
  .from("profiles")
  .update({ email_verified: true, verified_at: new Date().toISOString() })
  .eq("id", userId);

Never expose the service-role key to the browser. Keep it server-only.

Step 5: Pattern for the verify-email gate component

Your gate component should refetch the user record on mount, not rely on the auth context's cached value alone.

"use client";
import { useEffect, useState } from "react";
import { createBrowserClient } from "@supabase/ssr";

export function VerifyEmailGate({ children }: { children: React.ReactNode }) {
  const [status, setStatus] = useState<"loading" | "verified" | "unverified">("loading");

  useEffect(() => {
    const supabase = createBrowserClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    );

    // Fresh fetch, not the cached session
    supabase.auth.getUser().then(({ data, error }) => {
      if (error || !data.user) {
        setStatus("unverified");
        return;
      }
      setStatus(data.user.email_confirmed_at ? "verified" : "unverified");
    });
  }, []);

  if (status === "loading") return <p>Loading…</p>;
  if (status === "unverified") return <VerifyEmailScreen />;
  return <>{children}</>;
}

The key call is getUser(), which forces a network round-trip to Supabase Auth and returns the current authoritative user state. The cached session.user from getSession() is what causes the loop — it lies about verification status until the next refresh.

Step 6: Recover stuck users via admin panel

For users already in the loop, manual recovery is fast and safe.

-- One user at a time
UPDATE auth.users
SET email_confirmed_at = NOW()
WHERE email = 'stuck-user@example.com';

-- Bulk recovery for an incident window (be careful)
UPDATE auth.users
SET email_confirmed_at = NOW()
WHERE email_confirmed_at IS NULL
  AND created_at BETWEEN '2026-05-20 00:00' AND '2026-05-22 23:59';

Notify the affected users by email: "We fixed a verification bug. You no longer need to click the link. Please log out and log back in." The logout is necessary because their cached JWT still says unverified — until they get a fresh token, the frontend will show the gate.

In Base44's admin panel without direct DB access, open the Users table, find the user, edit the email_confirmed_at or verified field, save. The same logout-and-login rule applies.

Step 7: Handle email-scanner token consumption

If tokens consistently return token_already_used on first click for users on certain email providers, an email security scanner is consuming them. Diagnostic: send a verification email to a user on the affected provider, then immediately query the token-usage logs. If the token is consumed before the human clicks, you have a scanner.

Two fixes:

Option A: Switch to OTP codes. Send a 6-digit code in the email body instead of a clickable link. The user types it into your app. No scanner can consume what nobody can click.

// Send OTP
await supabase.auth.signInWithOtp({
  email,
  options: { shouldCreateUser: false, emailRedirectTo: undefined },
});

// Verify OTP
const { error } = await supabase.auth.verifyOtp({
  email,
  token: codeFromUser, // user-typed 6-digit code
  type: "email",
});

Option B: Add a manual-click interstitial. The link points to a page that says "Click here to confirm your email." Only after the human click does your page invoke the token-exchange. Scanners typically do not execute JS, so the token survives the pre-fetch.

// /auth/confirm page
"use client";
export default function ConfirmPage() {
  const code = new URLSearchParams(window.location.search).get("code");
  return (
    <button
      onClick={async () => {
        // Only run exchange on manual click
        const res = await fetch(`/auth/callback?code=${code}`);
        if (res.ok) window.location.href = "/dashboard";
      }}
    >
      Confirm my email
    </button>
  );
}

Option A is more reliable for B2B audiences. Option B keeps the magic-link UX but adds one click.

Step 8: Add a resend-verification button on the gate

Every verify-email gate should expose a resend control. This turns a permanent loop into a recoverable one.

async function resend() {
  const { error } = await supabase.auth.resend({
    type: "signup",
    email: currentUser.email,
  });
  if (error) toast.error("Could not resend. Please try again in 60 seconds.");
  else toast.success("New verification email sent.");
}

Rate-limit on the server side to once per 60 seconds per user to prevent abuse. Most providers already enforce this — your client-side button just gives a clean UI.

How long does it take to fix base44 email verification loops?

For a single Base44 app with a clear single cause, expect 30 minutes to 2 hours of focused work. The breakdown:

  • Reproduction and root-cause identification: 20-40 minutes.
  • Adding the /auth/callback route and updating the redirect allowlist: 15-30 minutes.
  • RLS audit and policy fixes (if relevant): 30-60 minutes.
  • Recovering stuck users via SQL: 5 minutes for one user, 15 minutes for a script that handles a batch.
  • Smoke testing across browsers and email clients: 30 minutes.

If you have multiple causes stacked (most production apps do), plan a half-day. The reproduction-to-fix loop is fast once you have the callback route in place — most subsequent issues are RLS or stale-frontend-state problems that surface predictably.

If you also need to switch from magic links to OTP codes, add another 1-2 hours for the email template updates and the verify-OTP UI component.

DIY vs hire decision

DIY this if: You have access to your Supabase or Base44 admin panel, you can read the handler logs, and you have at least one teammate who can write a Next.js route handler. The fix is well-documented and the surface area is small.

Hire help if: Verification has been broken for more than 48 hours, users are churning at signup, your incident is touching paid customers, or you cannot identify which of the five causes you are hitting. The longer the loop persists, the more affected users you accumulate, and the harder the recovery email gets to write.

Hire help if: You are also seeing related auth issues — SSO callback failures, session timeouts, locked-out users. Multiple auth bugs at once usually means a configuration problem in your provider that needs an experienced eye, not five separate hot-patches.

Need this fixed for a launch this week?

Our fix-sprint engagement is built for this scenario: identifies which of the five causes are active in your app, ships the callback route and RLS fixes, recovers stuck users via a one-time script, and validates the flow end-to-end across desktop and mobile email clients. Typical turnaround is 24-48 hours from start to verified resolution. We also run a regression test plan that catches the most common follow-on issues (session refresh, gate component drift, expired-token handling).

Start a fix-sprint engagement for the email verification loop

QUERIES

Frequently asked questions

Q.01Why does my Base44 email verification keep looping back to the verify screen?
A.01

The loop happens because clicking the link sends the user back to your app, but the route they land on never runs the token-exchange handler that flips their `email_confirmed_at` flag. The frontend then re-checks the session, sees no confirmation timestamp, and re-mounts the verify-email gate. The link itself looks like it worked — the browser navigates, the URL changes, the page reloads — but the underlying database record was never updated. The fix is an explicit callback route (`/auth/callback`) that calls `exchangeCodeForSession` with the token from the URL, writes the confirmation timestamp, and forces an auth refresh before redirecting to the destination. Without that handler in place, the verification link is decorative.

Q.02Why does the verification link work in one browser but not another?
A.02

Two reasons. First, email security scanners (Outlook Safe Links, Mimecast, Proofpoint, Gmail's URL warmer) sometimes pre-fetch the verification URL the moment the email arrives. Tokens are typically single-use, so by the time the human clicks, the token is already consumed and returns an error that the frontend interprets as 'not verified.' Second, the verification handler may set the auth cookie with a `SameSite=Strict` flag that breaks cross-origin redirects. Test the link in an incognito window and check the network tab. If the token returns `already used` or `expired` on first click, you have a scanner consuming it. Switch to short-lived OTP codes or add a one-click confirm interstitial that requires a human click before invoking the token.

Q.03How do I manually mark a Base44 user as verified to unblock them?
A.03

Open the Base44 dashboard, navigate to the Users table (or `auth.users` if you connected Supabase), find the affected user, and set `email_confirmed_at` to the current timestamp. In Supabase, you can run `UPDATE auth.users SET email_confirmed_at = NOW() WHERE email = 'user@example.com';` from the SQL editor. The user must then log out and back in for the frontend session to refresh; the cached JWT still says unverified until the next token issuance. For bulk recovery during an incident, write a one-time script that updates every user created in the affected window. Send those users a notification explaining what happened and that they no longer need to click the link.

Q.04How long do Base44 verification links stay valid?
A.04

Default expiry is typically 60 minutes for magic links and 24 hours for email-confirmation tokens, but this is configurable in your auth provider. If you are on Base44 with Supabase, check Authentication → Email Templates → settings for `OTP Expiry`. The shorter the expiry, the more often users hit an expired-link loop because they read email on a delay. For consumer apps, 24 hours is a reasonable default. For B2B apps where users may be away from desk, set it to 7 days. Always include a 'resend verification email' button on the verify-email screen so a stuck user can recover without contacting support.

Q.05Why does the verification handler succeed but the user still shows as unverified?
A.05

Three causes. First, Row Level Security on the user table denies the UPDATE statement from the verification handler — the handler runs as the anon role and cannot write to the `verified` column. Second, the handler updates a custom `profiles` table but the frontend reads from `auth.users.email_confirmed_at` (or vice versa). Third, the frontend caches the old session in localStorage and never re-fetches after the callback. Verify the database write happened by querying the user record directly. If it did, the bug is in the frontend session refresh. If it did not, check the handler logs for an RLS policy error and add a server-side policy that allows the verification handler (using the service role key) to update the column.

Q.06Should I switch from email links to one-time codes (OTP) to avoid the loop?
A.06

Often, yes. OTP codes (a 6-digit number the user types into the app) sidestep almost every link-based failure mode: no email scanner pre-fetch, no broken redirects, no `SameSite` cookie issues, no forwarded-link weirdness. The user receives the code, types it into your app, and your backend verifies it directly — no callback route required. Trade-off: slightly more friction (typing six digits vs clicking a link) and a small risk of mistyped codes. For high-value B2B accounts the trade is worth it. Most modern auth providers (Supabase, Clerk, Auth0) offer OTP as a drop-in alternative to magic links — flip the toggle and update your email template.

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.