Joan Comadran
Field notes

Engineering

Supabase email confirmation not working in Next.js: every cause and the fix

Supabase confirmation link fails, says 'Email link is invalid or has expired', throws 'PKCE code verifier not found', redirects to localhost or never arrives? The real causes in a Next.js app, how to tell which one you have, and the route I run in production.

October 19, 202612 min read

If Supabase email confirmation isn't working in your Next.js app, the most likely cause is PKCE: the default link ends in exchangeCodeForSession, which only works in the browser where the user signed up. Open the email on a phone and you get PKCE code verifier not found in storage. The fix is to change the Confirm signup template to link to your own route and verify the token_hash there:

html
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next=/onboarding">
  Confirm your email
</a>
ts
// app/auth/confirm/route.ts
const { error } = await supabase.auth.verifyOtp({ token_hash, type: 'email' });

If that's not your symptom, the table below maps the other ones. All of this comes from the sign-up flow of Claient, a multi-tenant WhatsApp lead agent on Next.js and Supabase, where I hit most of these between June and July 2026. Facts about Supabase defaults are as of October 2026.

Which one do you have?

SymptomLikely causeFix
PKCE code verifier not found in storageLink opened in another browser, app or devicetoken_hash + verifyOtp route
Email link is invalid or has expired / otp_expiredLink already used, older than 1 hour, or opened by a mail scannerResend flow; click-to-confirm page for corporate mail
Link lands on localhost:3000Site URL never changed, or emailRedirectTo not on the allow list or relativeFix URL Configuration, absolute URLs
No email at allBuilt-in sender's 2 emails/hour limit, or the address is already confirmedCustom SMTP; check identities
Clicked the link, still email_not_confirmedConfirm route behind auth middleware, or a trigger on auth.users failingMake the route public, debug the trigger

With @supabase/ssr, a Next.js app uses the PKCE flow. When the user signs up, the client stores a code verifier in a cookie. The default {{ .ConfirmationURL }} goes to Supabase's /verify endpoint, which redirects back to your app with ?code=..., and exchangeCodeForSession(code) sends the code plus that verifier to get a session.

No verifier, no session. That happens whenever the click doesn't come from the sign-up browser: the Gmail app's in-app browser, a phone when the user signed up on a laptop, a private window. The auth code also expires after 5 minutes and works once.

My first version of the Claient callback (commit 5cf4697, June 2026) was exactly the textbook PKCE handler:

ts
// The first version: works only in the sign-up browser
if (code) {
  const { error } = await supabaseServer().auth.exchangeCodeForSession(code);
  if (!error) return NextResponse.redirect(`${origin}${next}`);
}
return NextResponse.redirect(`${origin}/login?error=Could not confirm the account`);

It works as long as the click comes from the sign-up browser, which is exactly what you do when you test it yourself. Two days later (436f7d0) it learned the token_hash path, which doesn't depend on cookies at all. verifyOtp sends the hashed token to Supabase and gets a session back, so it works from any device. That's why Supabase's own Next.js examples use an /auth/confirm route with verifyOtp for email links.

The route I run in production

This is the Claient route, translated and trimmed (in Claient it lives at /auth/callback). It handles both link shapes, because which one arrives depends on the template, and it reads the error Supabase attaches when its own /verify step fails:

ts
// app/auth/confirm/route.ts
import { NextRequest, NextResponse } from 'next/server';
import type { EmailOtpType } from '@supabase/supabase-js';
import { createClient } from '@/lib/supabase/server';

export async function GET(req: NextRequest) {
  const { searchParams, origin } = req.nextUrl;
  const tokenHash = searchParams.get('token_hash');
  const type = searchParams.get('type') as EmailOtpType | null;
  const code = searchParams.get('code');
  const next = internalPath(searchParams.get('next'), '/onboarding');

  const fail = (msg: string) =>
    NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(msg)}`);

  // Supabase redirects with ?error_description=... when the link was expired or used
  const supabaseError = searchParams.get('error_description') || searchParams.get('error');
  if (supabaseError) return fail(friendly(supabaseError));

  const supabase = await createClient();

  // token_hash: stateless, works on any device
  if (tokenHash && type) {
    const { error } = await supabase.auth.verifyOtp({ type, token_hash: tokenHash });
    return error ? fail(friendly(error.message)) : NextResponse.redirect(`${origin}${next}`);
  }

  // code: PKCE, only works in the browser that started the flow
  if (code) {
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    return error ? fail(friendly(error.message)) : NextResponse.redirect(`${origin}${next}`);
  }

  return fail('This confirmation link is not valid.');
}

// `next` comes from the URL: only allow internal paths ('//evil.com' is an open redirect)
function internalPath(raw: string | null, fallback: string) {
  if (raw?.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/\\')) return raw;
  return fallback;
}

function friendly(raw: string) {
  const m = raw.toLowerCase();
  if (m.includes('expired')) return 'This link has expired. Sign in and we will send a new one.';
  if (m.includes('invalid') || m.includes('used')) return 'This link was already used. If you confirmed, just sign in.';
  return 'Could not confirm the account. Please sign in.';
}

Three details that each cost me a commit:

  • The next guard. A ?next= parameter you redirect to is an open redirect unless you check it. Claient's callback has had this guard since the audit fixes, and 03f1753 moved it into one shared helper after a code review found the same protocol-relative hole (//other-site.com) in another redirect. I wrote about the same trap in the pre-launch security checklist.
  • Async client. On Next.js 15 and later cookies() is async, so the server client factory is await createClient(). Claient predates that and its helper is synchronous.
  • Same route for password recovery. resetPasswordForEmail points at it with next=/reset-password, so one route verifies every email link.

This one (otp_expired, a 403 on /verify) is about the token, not your code. Three causes:

  1. It was already used. Users double-click, or click the second of two emails after the first one already confirmed them.
  2. It expired. Email links last 1 hour by default. You can raise it under Authentication > Sign In / Providers > Email > Email OTP expiration, but Supabase discourages anything over a day.
  3. A scanner opened it first. Corporate mail security (Microsoft Defender Safe Links is the usual example in Supabase's docs) fetches every link to check it. With a link that confirms on GET, the scanner confirms for you and the user's click fails. Your own /auth/confirm route has the same problem, since it verifies on GET too. If your users are on corporate email, link to a page with a "Confirm" button that does the verifyOtp call, or send the 6-digit {{ .Token }} code and verify it with verifyOtp({ email, token, type: 'email' }).

The fix that mattered more for Claient was the dead end. The login page used to say "confirm your account, check your email", and if that email was expired there was no way to get another one. Since d8f1009, signing in with an unconfirmed account resends the email on the spot:

ts
const { error } = await supabase.auth.signInWithPassword({ email, password });

if (error?.code === 'email_not_confirmed') {
  const { error: resendError } = await supabase.auth.resend({
    type: 'signup',
    email,
    options: { emailRedirectTo: `${appUrl()}/auth/confirm` },
  });
  // tell the user it was resent, or to wait a minute if resend was rate limited
}

Before that, Claient showed the same "wrong email or password" message for both cases (18678b9 split them). Telling users their account exists but isn't confirmed, and handing them a fresh link, is the difference between a stuck user and a signed-in one.

Why does the confirmation email redirect to localhost?

Supabase uses emailRedirectTo only if it matches an entry in the Redirect URLs allow list. Otherwise it falls back to the Site URL, and a new project's Site URL is http://localhost:3000. So:

  • Set the Site URL to your production domain in Authentication > URL Configuration.
  • Add each redirect you use. Matching is exact unless you use wildcards: https://example.com/** for a whole domain, a Vercel preview pattern if you test confirmation on previews.
  • Always send an absolute URL. Claient originally built the redirect from NEXT_PUBLIC_APP_URL with an empty-string fallback, so without the variable it became a relative /auth/callback. Supabase ignores relative redirects, and the password recovery flow broke silently. 03f1753 replaced it with an appUrl() helper that always returns an absolute URL.

If your template uses {{ .SiteURL }}, the link goes to the Site URL no matter what you pass. Use {{ .RedirectTo }} in the template if you need per-environment links.

Why is the Supabase confirmation email not arriving?

  • The built-in sender is for testing. As of October 2026 it allows 2 emails per hour per project. Two test sign-ups and you're done for the hour. Set up custom SMTP before you launch; the limit becomes configurable after that.
  • Per-user cooldown. The same address has to wait 60 seconds between confirmation requests, so a quick "resend" gets rate limited.
  • The address is already confirmed. To avoid revealing which emails have accounts, signUp on a confirmed address returns a fake user with an empty identities array and no session. Claient checks data.user.identities.length and shows the same neutral "check your email" screen, with links to sign in or reset the password.
  • Spam folder. Default sender, default template, no custom domain: it happens. Custom SMTP on your own domain helps here too.

The user clicked, but still gets "Email not confirmed"

Then the verify step didn't succeed. Check in this order:

  1. Is the confirm route public? If your middleware.ts (renamed proxy.ts in Next.js 16) redirects signed-out users to /login, and its matcher covers /auth/confirm, the user is bounced before anything verifies. Claient's matcher only covers /dashboard/:path*; in pollitics.es the proxy matcher lists the few routes that need the session and nothing else.
  2. Does a trigger on auth.users fail? A function that creates a profile on sign-up or confirmation and throws (a renamed column, a text-to-uuid cast) makes the confirmation fail with a database error. Supabase's GitHub issues are full of this one. Claient avoids the trigger: the sign-up server action runs an idempotent account setup with the service key instead, which is easier to debug and retry.
  3. Is it the right project? A preview deployment pointing at a different Supabase project than the one that sent the email. Boring, but worth ruling out.
  4. Read the Auth logs. The /verify request and its error code tell you which of the sections above you're in.

A 60-second checklist

  1. Confirm signup template links to your route with token_hash and type=email, not {{ .ConfirmationURL }} plus exchangeCodeForSession.
  2. The route calls verifyOtp, validates next, and turns Supabase's errors into messages users understand.
  3. Site URL is production; every emailRedirectTo is absolute and on the allow list.
  4. Custom SMTP before launch.
  5. Sign-in with email_not_confirmed resends the email instead of leaving users stuck.
  6. The confirm route isn't behind your auth middleware or proxy.

Once confirmation works, the next Supabase error people hit is usually the first insert right after sign-up. That's new row violates row-level security policy. If you're putting Claude behind that login, building a WhatsApp bot with Claude covers the part of Claient after onboarding.

FAQ

Why is Supabase email confirmation not working in my Next.js app?

Usually because a Next.js app using @supabase/ssr runs the PKCE flow, and the default confirmation link ends in exchangeCodeForSession, which needs a code verifier cookie stored in the browser where the user signed up. If the email is opened in another browser, app or device, the exchange fails with "PKCE code verifier not found in storage". Point the Confirm signup email template at your own route with token_hash and type, and call supabase.auth.verifyOtp({ token_hash, type: 'email' }) there. That works on any device.

The token was already used, has expired (email links last 1 hour by default, configurable under Email OTP expiration), or was consumed by an email security scanner that opened the link before the user did. Check the Auth logs for otp_expired. Give users a way to request a new email, for example by calling supabase.auth.resend({ type: 'signup', email }) when sign-in returns email_not_confirmed, and if your users are on corporate mail, make the link land on a page that only verifies after a button click, or send the 6-digit {{ .Token }} code instead.

Why does my Supabase confirmation email redirect to localhost?

Supabase falls back to the Site URL when no valid redirect is provided, and new projects start with http://localhost:3000. Set the Site URL to your production domain under Authentication > URL Configuration, add every emailRedirectTo you use to the Redirect URLs allow list (it is an exact match unless you use wildcards), and always pass an absolute URL. A relative emailRedirectTo is ignored.

Why is the Supabase confirmation email not arriving?

As of October 2026, the built-in email provider is limited to 2 emails per hour per project and is meant for testing, and the same user has to wait 60 seconds between confirmation requests. Configure custom SMTP before launch. Also check whether the address was already confirmed: in that case signUp returns an obfuscated user with an empty identities array and there is nothing to confirm.

Should I use verifyOtp or exchangeCodeForSession for email confirmation?

For email links in a server-rendered Next.js app, verifyOtp with token_hash. It is stateless and does not depend on cookies from the sign-up browser. exchangeCodeForSession is for flows that start and finish in the same browser, such as OAuth sign-in, and its auth code is valid for 5 minutes and single use.

The user confirmed but still gets "Email not confirmed". Why?

The verify step never succeeded. Common reasons: the confirm route is behind your auth middleware or proxy and redirects to login before verifying, a trigger on auth.users throws during confirmation, or the user clicked an old link. Check the Auth logs for the /verify request, make sure the route is public, and test the trigger by inserting into the tables it writes.

Auth is the unglamorous part of every product I build, and the part users notice first when it breaks. If you want a sign-up flow like this one built into your product, here's how I work.

Work with me

Want this in your product?

I build AI agents, apps and websites for companies.

Let's talk →

Keep reading

03