HeHeedza

Why does Supabase say "Email link is invalid or has expired" right after I click it?

Last reviewed

Short answer

An email security scanner (such as Microsoft Safe Links) or a link preview opened the one-time link before your user did, which used it up. Send users to your own page with a Continue button that verifies a token_hash, or send a 6-digit code instead of a link.

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.

  1. 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 }}.

  1. Build /auth/confirm as 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_expired events 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.

Fix prompt
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.

Questions

Why does it only fail for some users?

Prefetching depends on the recipient's mail provider and security tools. Personal inboxes often work fine while company inboxes behind link scanners fail.

Will a longer expiry time fix it?

No. The link isn't expiring. It's being used up by the scanner. A confirm button or a 6-digit code is the fix.

What type should I pass to verifyOtp?

Use 'email' for magic links and signup confirmation, and 'recovery' for password resets. It should match the type you put in the email template.

Does the PKCE flow cause this too?

It can cause a similar failure when the link is opened in a different browser or device from where sign-in started. The token hash approach above avoids that dependency.

Sources