For builders shipping this week
"Email link is invalid or has expired": what it means
FirstUser signs up to your app like a brand-new visitor, clicks the email link, and tells you where it breaks. Screenshot of the failed step, plain-English diagnosis, and a fix prompt for Lovable, Bolt, Cursor or Replit. Run a free signup test
Your new user signed up, the confirmation (or magic link, or password reset) email arrived, they clicked it, and instead of being logged in they see an error, or land on your site with #error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired in the address bar.
Supabase, the backend behind most Lovable apps, uses one-time tokens for these links. The message means the token was already used, has expired, or cannot be matched to this browser. Your own test account often works because you click fast, in the same browser, from a personal inbox. A real first user does not.
1. A mail scanner clicked the link first (most common)
Supabase's own troubleshooting guide names email prefetching as the most common cause: company mail security (for example Safe Links in Microsoft Defender for Office 365) and some mail apps open every link to check it. The link is one-time, so the scanner uses it up and your user's click fails.
How to confirm: in Supabase, look at the Auth logs for otp_expired and the API logs for 403 responses on /verify a few seconds after the email was sent, before a person could have clicked.
Fix, pick one:
- Put the 6-digit code
{{ .Token }}in the email template and add a screen where the user types it; verify it withsupabase.auth.verifyOtpfor email. A scanner has nothing to use up. - Or point the link at your own page, for example
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=signup, and only callverifyOtpwhen the user presses a Confirm button. Do not verify on page load, or the scanner confirms instead of the user.
2. The link is simply old or was already used
Email links expire (by default after one hour; you can change it under the email provider's OTP expiry setting in Supabase Auth, but Supabase advises against very long values). They are also single-use: a user who clicks twice, or clicks the first of two emails after the second one already confirmed them, gets the same error.
Fix: give users a way out. On the error page and on login, show "Send a new link". If signInWithPassword returns email_not_confirmed, call supabase.auth.resend for a signup email instead of leaving them stuck.
3. The link opened in a different browser or device
With the PKCE flow (the default in newer Supabase setups), the login is tied to the browser that started it. If the user signs up on a laptop and taps the link on their phone, or the mail app opens its own in-app browser, the exchange fails, sometimes with "both auth code and code verifier should be non-empty".
Fix: use the token-hash link and verifyOtp route from check 1 (it does not depend on the original browser), or the 6-digit code.
4. The link points to the wrong address
If the Site URL or Redirect URLs in Supabase still point to the Lovable preview, localhost, or the other variant of your domain (www vs. bare), the token may be verified but the user lands somewhere without a session, or on a dead page. See our guide on the confirmation email for the URL settings.
5. Test it like a stranger, not like the builder
Sign up on the published URL in a private window, with an address that has never been used, ideally at a provider different from your own (Outlook/company mail behaves differently from Gmail). Click the link once. Then try again after an hour. Watch the Auth logs while you do.
Let FirstUser do step 5 for you
FirstUser opens your published URL as a new visitor, signs up with a fresh identity, opens the confirmation email when it can, logs in and continues to checkout. If the link fails, you get the screenshot, the error it saw, and a fix prompt you can paste into Lovable.
Run a free signup test·Daily checks from $19/month: Pricing
FAQ
Is my Supabase project broken?
Usually not. The link worked for something, just not for your user (a scanner, a second click, or a different browser).
Should I just make links last longer?
It helps only for old links. It does nothing against scanners or a different browser.
Does this also affect password reset and magic links?
Yes, the same one-time links are used, so the same checks apply. The password-reset note is on the German blog post.