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.
- Open a fresh incognito window. Sign up with a new email address you control (use Mailosaur, Mailtrap, or a plus-addressed Gmail).
- Wait for the verification email. Note the timestamp.
- 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/verifyand you have not written a callback handler at that path, the link is decorative. - 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';. Noteemail_confirmed_atis null. - In a separate browser tab, open DevTools → Network. Click the verify link. Watch the network panel. Note every request, every redirect, every cookie set.
- Look for a request to your token-exchange endpoint (typically POST to
/auth/v1/verifyor your own/auth/callback). If you see no such request, the link did not trigger an exchange — you have cause #1. - After the click completes and you land back on the verify gate, re-query the user record. Look at
email_confirmed_atagain.- 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.
- 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 expiredmessage tells you which cause you have. - 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/callbackroute 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
Related problems
- Base44 white screen 405 after login — adjacent auth failure that often surfaces from the same misconfigured callback URL allowlist.
- Auth bypass: SSO vulnerable — the more dangerous auth failure to audit immediately after fixing verification.
- Data loss on return to app — the session-refresh symptom you may also be hitting if your gate component reads stale auth state.