How to fix: Supabase magic link error otp_expired: email link is invalid or has expired
This Supabase failure usually means an email security scanner opened the magic link first and consumed the one-time token, so the real click lands on an already-spent link and returns otp_expired or access_denied. Switch the flow to a typed 6-digit code with verifyOtp and this failure class disappears.
This pattern is common in AI-built Next.js and Supabase apps because generated code often leaves auth, cookie, deployment, or type boundaries unfinished. The exact symptom matters, so preserve the original error before changing code.
Why it happens
An email security scanner pre-fetched the magic link to scan it, and that automated visit consumed the one-time token before the user ever clicked. The real click then lands on an already-spent link, so Supabase returns otp_expired or access_denied even though the link is fresh.
What to check
Confirm the link fails even when clicked within seconds of arrival; a genuinely fresh link that still reports expired is the scanner signature, not a timeout.
Test with inboxes on different providers (personal Gmail vs corporate Microsoft 365); scanner behavior differs by provider and policy, which is why it works for some users and not others.
Try the 6-digit OTP code flow for the same account; if the typed code works while the clicked link fails, the link is being consumed upstream of the user.
On web clients, verify the URL hash actually reaches your auth handler; some routers strip the fragment before detectSessionInUrl can read it.
Fix plan
Switch from the clicked link to the typed code: keep signInWithOtp to send the email, include the {{ .Token }} 6-digit code in the email template, and call verifyOtp({ email, token, type: 'email' }) when the user types it.
A typed code cannot be pre-fetched by a scanner, so this whole failure class disappears; it is also easier to debug locally because there is no URL fragment round trip.
If a clickable link must stay, read and store the fragment before any client-side router navigates, and expect scanners to keep consuming some share of links.
Retest from the exact inbox type your real users have, not only your own developer inbox.
When to stop guessing
If this touches auth, RLS, database writes, storage, redirects, or deployment callbacks, a build-only fix is not enough. Verify the real user path against the same Supabase project and domain that failed.
Need a second set of eyes? Paste the exact error into the free diagnosis form and get a focused rescue plan before you spend more time guessing.