Why this happens
Supabase confirmation and magic links are single-use. The default {{ .ConfirmationURL }} link consumes the token the moment anything requests it. Some mail providers and corporate security tools request every link in incoming mail to check it for malware. Your user then clicks a link that has already been used, and Supabase answers with otp_expired, "Email link is invalid or has expired", or a 403 on /verify.
Supabase's own troubleshooting guide names email prefetching as the most common cause. Other causes are worth ruling out:
- The user clicked an older email after requesting a new one.
- With the PKCE flow, the link was opened in a different browser or device from where sign-in started.
- The link really did pass its expiry window.
We ran into the prefetch problem on Heedza's own sign-in emails, which is why our links now land on a confirm page with a button.
How to fix it, step by step
Option A (keep a link): use a token hash link to your own confirm page.
- In Supabase, open Authentication → Email Templates. Change the Magic Link and Confirm signup templates so the link goes to your site rather than straight to Supabase:
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Continue to sign in</a>For the Reset password template, use type=recovery. If your code passes a redirect, you can use {{ .RedirectTo }} in place of {{ .SiteURL }}.
- Build
/auth/confirmas a page that does nothing on load and verifies only when the person clicks. A scanner fetching the URL doesn't click buttons:
import type { EmailOtpType } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase';
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?error=link' : '/');
}
return <button onClick={confirm}>Continue</button>;
}Important: don't verify the token in a GET route handler or a useEffect. That puts you back where you started.
Option B (no link to consume): send a 6-digit code. Put {{ .Token }} in the template and verify what the user types:
const { error } = await supabase.auth.verifyOtp({ email, token: code, type: 'email' });Also:
- Turn off click tracking with your email provider. Supabase warns that tracking rewrites auth links.
- Show a friendly "This link was already used. Send a new one?" message with a resend button, instead of the raw error.
- Check Logs → Auth for
otp_expiredevents a few seconds after the email was sent. That timing points to prefetching.
Fix prompt for your AI builder
Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.
Users get "Email link is invalid or has expired" (otp_expired) right after clicking our Supabase sign-in email, because email scanners pre-open the link. Please:
1. Add a /auth/confirm page. It reads token_hash and type from the URL and calls supabase.auth.verifyOtp({ token_hash, type }) only when the user clicks a "Continue" button. Never verify on page load or in a GET handler.
2. On success go to the home page. On error, show "This link has expired or was already used" with a button that sends a new link.
3. Give me the exact HTML to paste into the Supabase Magic Link, Confirm signup and Reset password templates, using {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email (type=recovery for reset).
Don't change other auth flows.How to catch this next time
This bug usually hits your most valuable signups: people on company email behind Microsoft 365 or similar security tools. They often just leave rather than report it.
Heedza checks on a schedule that your live signup and login pages load and show the email field, and it emails a screenshot and a fix prompt if they break. It doesn't read your users' inboxes, so test the email itself with a work address after you change templates. Run the free scan to see what Heedza checks on your live site.