A user signs up, opens the confirmation email, clicks the button, and lands back on your app with nothing working. The address bar ends in something like #error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired. The same thing happens with password reset links, magic links and invites.

I see this a lot in Lovable, Bolt and Vite apps that use Supabase Auth, and the usual reaction is to make the link last longer. That rarely fixes it. In most cases the link didn't expire at all: something else used it first.

Below is how I work out which case it is and what to change. It's based on Supabase's OTP verification failures troubleshooting guide, the Email Templates guide, the Passwordless email logins guide, the Password-based Auth guide and the Auth error codes page, all read on 14 November 2026.

What the error means

Every Supabase email link carries a one-time token. The default link, {{ .ConfirmationURL }}, points at your project's /auth/v1/verify endpoint, which checks the token and then redirects to your app. If the token is no longer valid, Supabase still redirects, but with the error in the URL fragment instead of a session.

The troubleshooting guide groups the messages together: "token has expired or is invalid", "Email link is invalid or has expired", a 403 Forbidden on the verify endpoint, and otp_expired in the Auth logs. The error codes page describes otp_expired as "OTP code for this sign-in has expired. Ask the user to sign in again."

The wording says "expired", but Supabase uses it for a token that was already used or replaced, too. There are three causes worth checking.

Cause 1: something opened the link before your user did

Supabase's troubleshooting guide calls email prefetching "the most common reason" for tokens that look expired before the user can use them. Some email providers and security tools open every link in an incoming email to check it. The Email Templates guide names Safe Links in Microsoft Defender for Office 365 as an example and says that in this case the {{ .ConfirmationURL }} "will be consumed instantly", which leads to the "Token has expired or is invalid" error.

The scanner gets the one valid use. When your user clicks a few seconds or minutes later, the token is gone. There is a long-running issue about this on the Supabase Auth repository, Email Links don't pass through enterprise email systems, where people describe exactly this with reset and invite links.

This is why it often shows up for users on company email while your own test sign-ups work fine.

Cause 2: a newer email replaced it

If the user asks for another email ("Resend confirmation", "Forgot password" twice), the older link stops working. In the Supabase Auth source, CreateOneTimeToken deletes the user's existing token of the same type before it saves the new one. The first email still looks fine in the inbox, and its link fails.

Cause 3: it really did expire

The passwordless guide says that by default a user can request a magic link or OTP once every 60 seconds and it expires after 1 hour. You can change it under Authentication > Sign In / Providers > Auth Providers > Email > Email OTP expiration. The same setting "also governs the validity of Magic Links and other email links, including confirmation, password recovery, email change, and invitation links."

Supabase says an expiry "of more than 86,400 seconds (one day) is strongly discouraged". A longer window helps the person who opens the email tomorrow. It does nothing for a scanner that opens it in the first second.

Find out which one you have

The troubleshooting guide's method, in short:

  1. In your Supabase project, open Logs and check the API logs for 403 responses on /verify.
  2. Match them to the Auth logs for otp_expired.
  3. Compare the times with when the email was sent. In Supabase's words: "If you see rapid 403 errors or otp_expired messages shortly after an email is sent, but before a user could reasonably click the link, it strongly suggests prefetching."

If the failure comes hours later, it's cause 3. If the user had several emails, it's probably cause 2. If it comes within seconds of sending, it's cause 1, and changing the expiry won't help.

Fix 1: send a code instead of a link

The most robust fix is to stop putting a consumable link in the email. The templates can include {{ .Token }}, a 6-digit code. Scanners don't type codes.

Edit the template under Authentication > Email Templates, for example:

<h2>Confirm your email</h2> <p>Enter this code in the app: {{ .Token }}</p> 

Then, on a page where the user types the email and code, verify it, as the Email Templates guide shows:

const { data, error } = await supabase.auth.verifyOtp({ email, token, type: 'email', }) 

For password resets, the troubleshooting guide uses type: 'recovery'.

Fix 2: keep a link, but verify only on a click

If you want to keep a link, send the user to your own page first and verify only when they press a button. Use {{ .TokenHash }} to build the link, as the passwordless guide does:

<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Confirm email</a> 

For a reset email, the passwords guide uses type=recovery. On that page, read the query string and call verifyOtp when the user clicks, not when the page loads. In a React app from Lovable or Bolt it can be this small:

import type { EmailOtpType } from '@supabase/supabase-js' import { supabase } from '@/integrations/supabase/client' export default function ConfirmPage() { async function confirm() { const params = new URLSearchParams(window.location.search) const { error } = await supabase.auth.verifyOtp({ token_hash: params.get('token_hash') ?? '', type: (params.get('type') ?? 'email') as EmailOtpType, }) window.location.replace(error ? '/login?link=expired' : '/') } return <button onClick={confirm}>Confirm my email</button> } 

Adjust the import to wherever your app creates its Supabase client. The verifyOtp call with token_hash and type comes from the Email Templates guide.

The important part is the click. Supabase's own server examples verify the token as soon as the /auth/confirm route is requested. That's fine for most mail, but a scanner that requests the URL runs the same code. One report on Supabase's agent-skills repository shows a scanner's HEAD request confirming the token one to two seconds before the user's own click failed. So no verifyOtp in a useEffect or on page load.

The Email Templates guide also offers a second variant: link to your page with the original {{ .ConfirmationURL }} as a query parameter, and put that URL behind a button.

Fix 3: turn off click tracking

If you send auth emails through your own SMTP provider, check its settings. The Email Templates guide says that with "email tracking" on, "the links inside the Supabase email templates will be overwritten and won't perform as expected", and recommends turning it off.

Don't leave users stuck

Whatever the cause, the user needs a way out. Read the error from the URL and show a clear message with a button to send a new email, instead of a blank screen:

const hash = new URLSearchParams(window.location.hash.slice(1)) if (hash.get('error_code') === 'otp_expired') { // show "This link has expired or was already used" and a resend button } const { error } = await supabase.auth.resend({ type: 'signup', email, }) 

resend is documented in the Supabase JavaScript reference. Remember the 60-second request limit and that every resend replaces the previous link (cause 2), so tell the user to use the newest email.

Check the fix

Sign up with a fresh address and confirm. Request a password reset and use it. If your users are on company email, repeat with a Microsoft 365 mailbox if you can get one, since that is where prefetching shows up. Then check the Auth logs: no otp_expired seconds after sending.

Most cases I see come down to a scanner using the link first. A code in the email, or a page with a button, usually ends it.

If the link opens localhost:3000 instead of your app, that's a separate problem with the Site URL, and I wrote up that fix here: Supabase email link goes to localhost. And if your users can't get a new email at all, check Supabase email rate limit exceeded.

I wrote this with AI assistance and reviewed it myself.