DEV Community

Joan Comadran
Joan Comadran

Posted on Edited on Originally published at joancomadran.dev

Supabase email confirmation not working: fixes for Next.js

If Supabase email confirmation is not working, the URL after the click tells you which failure you have. localhost:3000 with error=access_denied and error_code=otp_expired means Auth rejected the token and sent the browser to the Site URL, which is still the default. PKCE code verifier not found in storage means the default link was opened outside the browser that signed up. Point the Confirm signup template at your own route and verify the token_hash there:

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

The other symptoms are in the table. 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. Supabase defaults below are as of November 2026.

Which one do you have?

What you see Likely cause Fix
?error=access_denied&error_code=otp_expired on localhost:3000, often repeated after # Token already used, past the 1 hour email OTP expiry, or prefetched. Redirect fell back to the default Site URL Read the hash or query on that page, then resend. Fix Site URL
PKCE code verifier not found in storage Link opened in another browser, app or device token_hash + verifyOtp
Email link is invalid or has expired Same dead token, before or instead of the redirect Resend. For corporate mail, confirm on a button click or send {{ .Token }}
No email at all Built-in sender (team addresses only, 2 per hour) or the address is already confirmed Custom SMTP. Check identities
Clicked, still email_not_confirmed Confirm route behind auth middleware, or a trigger on auth.users failing Make the route public, debug the trigger

What does the otp expired error URL mean?

People paste the whole redirect into Google. It looks like this:

http://localhost:3000/?error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired
Enter fullscreen mode Exit fullscreen mode

Three separate facts are packed into that string.

  • error_code=otp_expired is Auth's code for a dead email token. The description is always Email link is invalid or has expired, including when the token was never found because it was already used. error=access_denied is the OAuth label Auth attaches to that 403. It does not mean an admin blocked the user.
  • The same fields appear twice when the project is on the PKCE flow, which @supabase/ssr uses. Auth writes them into the query string and into the hash. On the implicit flow they land in the hash only. The hash also carries an empty sb parameter, which is how you know the redirect came from Supabase Auth.
  • The host is localhost:3000 because that is the default Site URL. A failed verify still redirects, and with no matching emailRedirectTo the target is the Site URL. The error page is your homepage, not /auth/confirm, so error handling that lives only on the confirm route never runs.

The token is already dead by the time this URL exists. These params are the failure report, not a token you can verify again. Send a new email.

How to read it in Next.js. The fragment stays in the browser. searchParams in a Server Component or Route Handler sees the query string (PKCE) and nothing after #. Read the hash on the client, on the page the redirect actually hits:

'use client';

import { useEffect, useState } from 'react';

export function AuthHashError() {
  const [message, setMessage] = useState<string | null>(null);

  useEffect(() => {
    const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''));
    const query = new URLSearchParams(window.location.search);
    const code = query.get('error_code') ?? hash.get('error_code');
    if (code === 'otp_expired') {
      setMessage('This link has expired or was already used. Sign in and we will send a new one.');
    }
  }, []);

  if (!message) return null;
  return <p>{message}</p>;
}
Enter fullscreen mode Exit fullscreen mode

useSearchParams() does not include the hash. If you only check that, the implicit-flow case looks like a normal visit to /.

PKCE code verifier not found in storage

With @supabase/ssr, sign-up stores a code verifier in a cookie. The default {{ .ConfirmationURL }} hits Supabase /verify, then redirects back with ?code=.... exchangeCodeForSession(code) sends that code plus the verifier.

No verifier, no session. That is the Gmail in-app browser, a phone when the user signed up on a laptop, a private window. The auth code itself lasts 5 minutes and works once. A second exchange, or one after those 5 minutes, comes back as flow_state_not_found or flow_state_expired. That is a different failure from otp_expired: the email token was fine, the PKCE handoff was not.

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

// 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`);
Enter fullscreen mode Exit fullscreen mode

It works when you test it yourself, in the same browser, within five minutes. Two days later (436f7d0) it learned the token_hash path. verifyOtp sends the hashed token and gets a session back, with no cookie from the sign-up browser. Supabase's Next.js examples use an /auth/confirm route for this.

The confirm route

This is the Claient route, translated and trimmed (in Claient it lives at /auth/callback). The template decides which shape arrives, and Supabase's own /verify attaches an error when the token is already dead. On PKCE that error is in the query string, so the route can see it:

// 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)}`);

  const supabaseError = searchParams.get('error_description') || searchParams.get('error');
  if (supabaseError) return fail(friendly(supabaseError));

  const supabase = await createClient();

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

  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.';
}
Enter fullscreen mode Exit fullscreen mode

Use type=email in the template. That is what the current Auth examples pass to verifyOtp, and the server accepts it for both signup confirmation and magic links. signup and magiclink are still on the JavaScript EmailOtpType union. The Python and Dart references mark those two as deprecated, so I would not start a new template with them. resend() is a different call: a confirmation resend is type: 'signup', and passing 'email' there is a type error.

Three details that each cost me a commit:

  • The next guard. A ?next= 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 review found the same protocol-relative hole (//other-site.com) on another redirect. I wrote about that trap in the pre-launch security checklist.
  • Async client. On Next.js 15 and later cookies() is async, so the server client is await createClient(). Claient predates that and its helper is synchronous.
  • Same route for password recovery. resetPasswordForEmail points at it with next=/reset-password. One route verifies every email link. Recovery uses type=recovery.

How do I fix "Email link is invalid or has expired"?

This is the same otp_expired 403, whether you see it as JSON from /verify or as the redirect above. Three causes:

  1. It was already used. Double-clicks, or the second of two emails after the first one confirmed them. Auth uses the same message when the token is missing and when it is past its expiry.
  2. It expired. Email links last 1 hour by default. The setting is Authentication > Sign In / Providers > Email > Email OTP expiration, and it also covers magic links, password recovery, email change and invites. Anything over 86,400 seconds (one day) is discouraged and can only be set through the Management API.
  3. A scanner opened it first. Microsoft Defender Safe Links, and the same idea in other corporate gateways, fetches the URL to check it. {{ .ConfirmationURL }} confirms on GET, so the scanner spends the token and the user's click fails. A route that calls verifyOtp on GET has the same hole. If your users are on corporate mail, link to a page with a Confirm button that calls verifyOtp, or send the 6-digit {{ .Token }} and verify 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 an expired email had no way to get another one. Since d8f1009, signing in with an unconfirmed account resends on the spot:

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 if the resend was rate limited
}
Enter fullscreen mode Exit fullscreen mode

Before that, Claient showed "wrong email or password" for both cases (18678b9 split them). Saying the account exists but is not confirmed, and handing over a fresh link, is the difference between a stuck user and a signed-in one.

Why does the Supabase confirmation email redirect to localhost?

Auth uses emailRedirectTo only when it matches an entry in the Redirect URLs allow list. Otherwise it falls back to the Site URL. A new project's Site URL is http://localhost:3000.

  • Set the Site URL to your production domain in Authentication > URL Configuration.
  • Add each redirect you use. Matching is exact unless you add a wildcard. Supabase's own Vercel example is https://*-.vercel.app/**, plus http://localhost:3000/** for local. Keep production on an exact path.
  • Send an absolute URL. Claient originally built the redirect from NEXT_PUBLIC_APP_URL with an empty-string fallback, so a missing variable became a relative /auth/callback. A relative URL does not match the allow list, and the password recovery flow fell back to the Site URL without an error in the client. 03f1753 replaced it with an appUrl() helper that always returns an absolute URL.

If the template uses {{ .SiteURL }}, the link goes to the Site URL no matter what you passed as emailRedirectTo. Use {{ .RedirectTo }} when each environment should land on its own host.

Why is the Supabase confirmation email not sending?

  • The built-in sender is for testing, and only for your team. As of November 2026 it delivers only to email addresses on the Supabase organization's team. Any other address fails with email_address_not_authorized ("Email address not authorized"). It also sends 2 emails per hour for the whole project while Confirm email is on. Past that, Auth returns 429 over_email_send_rate_limit with email rate limit exceeded. The docs say that hourly cap can change without notice. Example domains such as user@example.com fail separately, with email_address_invalid.
  • Custom SMTP, or a Send Email hook, before launch. Either one lets you mail any address and change the limit under Authentication > Rate Limits. Custom SMTP starts you at 30 emails per hour until you raise it. Turning Confirm email off also removes the built-in hourly cap, and it marks whatever address the person typed as confirmed. Leave it on.
  • Per-user cooldown. The same address waits 60 seconds between signup confirmation requests by default (the same window applies to /otp and password recovery). It is configurable on the Rate Limits page. A quick resend inside that window is rejected.
  • The address is already confirmed. With confirmation required, signUp on an existing confirmed address returns a success and a fake user whose identities array is empty, and sends no email. The user id in that payload is not the real one. Claient checks data.user.identities.length and shows the same "check your email" screen, with links to sign in or reset the password. If confirmation is turned off, the duplicate comes back as "User already registered" instead.
  • Spam. Default sender, default template, no domain of your own. Custom SMTP on a domain with SPF, DKIM and DMARC is the usual way out.

Why does the user still get "Email not confirmed"?

Then the verify step did not succeed. Check in this order:

  1. Is the confirm route public? If middleware.ts (renamed proxy.ts in Next.js 16) sends 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*. On 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 and throws (a renamed column, a text-to-uuid cast) fails the confirmation with a database error. Claient skips the trigger: the sign-up server action runs an idempotent account setup with the service key, which is easier to retry.
  3. Is it the right project? A preview deployment pointed at a different Supabase project than the one that sent the email.
  4. Read the Auth logs. The /verify request and its error code tell you which section above you are in. otp_expired is the email token. flow_state_expired is the PKCE code.

A 60-second checklist

  1. Confirm signup template links to your route with token_hash and type=email. Keep {{ .ConfirmationURL }} plus exchangeCodeForSession for a flow that starts and finishes in the same browser.
  2. The route calls verifyOtp, validates next, and turns Auth's errors into a sentence a user can act on.
  3. The page the redirect actually lands on (often /) reads error_code from the query string and from the hash.
  4. Site URL is production. Every emailRedirectTo is absolute and on the allow list.
  5. Custom SMTP or a Send Email hook is on before real users sign up.
  6. Sign-in with email_not_confirmed resends, and the confirm route is outside the auth middleware or proxy.

Once confirmation works, the next Supabase error is usually the first insert after sign-up: new row violates row-level security policy.

Want this confirm route, the resend path and the URL config in place before the first user hits a dead link?

FAQ

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

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

What does the otp expired error URL mean?

Supabase Auth rejected the email token and redirected the browser to your Site URL, which on a new project is http://localhost:3000. error=access_denied is the OAuth label for that 403. error_code=otp_expired and the description "Email link is invalid or has expired" mean the token was already used, is past the email OTP expiry (1 hour by default), or was consumed by a mail scanner. On the PKCE flow the same fields are in the query string and in the URL hash. The hash never reaches a Route Handler, so read window.location.hash in a client component on the page the redirect lands on, often the site root.

Why does my Supabase confirmation email redirect to localhost?

Auth falls back to the Site URL when emailRedirectTo is missing or not on the Redirect URLs allow list, and a new project starts at http://localhost:3000. Set Site URL to your production domain under Authentication > URL Configuration, add every absolute redirect you use, and use {{ .RedirectTo }} in the template when the link should follow emailRedirectTo. A relative emailRedirectTo does not match the allow list.

Why is the Supabase confirmation email not sending?

As of November 2026 the built-in email provider only delivers to addresses on your Supabase organization's team (other addresses fail with email_address_not_authorized) and is limited to 2 emails per hour for the whole project. The same user also has to wait 60 seconds between confirmation requests by default. Configure custom SMTP or a Send Email hook before launch. Custom SMTP starts at 30 emails per hour until you raise it under Authentication > Rate Limits. A repeat sign-up of an already confirmed address returns a fake user with an empty identities array and sends no email.

Should I use verifyOtp or exchangeCodeForSession for email confirmation?

For email links, verifyOtp with token_hash and type email. It does not depend on cookies from the sign-up browser. exchangeCodeForSession is for a flow that starts and finishes in the same browser, such as OAuth, and that auth code is valid for 5 minutes and a single use. resend() for a confirmation email still takes type signup, not email. Current Supabase examples use type email for verifyOtp. The JavaScript EmailOtpType union still includes signup and magiclink; the Python and Dart references mark those two as deprecated.

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

The verify step never succeeded. Common reasons: the confirm route is behind 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, keep the route public, and test the trigger by inserting into the tables it writes.


Originally published on joancomadran.dev. I'm Joan, an AI engineer in Barcelona. work with me.

Top comments (0)

⌂ 换网址