Skip to content

Security basics

Supabase "Email link is invalid or has expired" on a fresh link

On a link sent a minute ago, Supabase's "Email link is invalid or has expired" usually means a mail filter used it first. How to check and fix it.

Vlad Tkachenko13 min read
An open envelope with a ticket half out of it, and the ticket already has a hole punched through it.

In short

  • Supabase shows "Email link is invalid or has expired" when the token in a sign-in or confirmation link has already been used, has been replaced by a newer email, or is more than an hour old. The error code is otp_expired.
  • On a link the person received a minute ago, the usual cause is a mail filter that opened the link to check it and used it up doing so. Supabase's own troubleshooting page calls this the most common reason.
  • A longer expiry changes nothing. Change the email so that opening the address does nothing: send a code to type, or link to a page where a button signs the person in.

A new user signs up, opens the confirmation email a minute later and presses the button. Your app opens on its home page, still signed out, and the address bar ends in this:

#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired

You sign up with your own address to check. The link works the first time, and every time after that.

Supabase says "Email link is invalid or has expired" when the token in an email link has been used already, has been replaced, or has outlived its hour. On a link that arrived a minute ago, the usual reason is that something used it before your user did: a mail filter that opens links to check them. Your Lovable, Bolt, v0, Cursor, Claude Code or Replit app is working as built, and the email it sends can be changed so that a filter has nothing to use up.

Here is the part the usual advice gets wrong: "ask them to try again" and "make the link last longer" both fail against a filter. A second link goes through the same filter and meets the same end, however long it would have lasted.

It means Supabase Auth, the part of Supabase that signs people in, could not find a token it was willing to accept behind the link.

The message is written into the Auth server's own code. In the function that checks an email link, it is returned in two places: when no waiting token matches the one in the link, and when the token that matches was sent longer ago than your project allows. Both answers are HTTP 403 with the code otp_expired, so the message alone cannot tell you which of the two happened.

Your user never sees the 403. The link in the email points at your Supabase project's own address, which checks the token and then sends the browser on to your app. When the check fails, Supabase puts three things in the part of your app's address after the #: error=access_denied, error_code=otp_expired and the message itself. If your app was set up with the PKCE flow, they are in the part after the ? as well. Unless a page in your app reads them, the person sees whatever your app shows a visitor who is not signed in, with no word about why. A password reset page that shows its form anyway fails on save with "Auth session missing!", because the link signed nobody in, and that message has a post of its own.

Think of the link as a ticket that admits one person, once. Whoever hands it in first walks through, and the ticket is punched. "Email link is invalid or has expired" is the person at the door holding up a ticket with a hole already in it, or one whose time ran out. If your users get in and are thrown out later, that is a different error with a page of its own.

Because something opened it before your user did, and opening a Supabase email link is the same as using it.

Supabase's email template guide lists this under its limitations: "Certain email providers may have spam detection or other security features" that "prefetch URL links from incoming emails". The example it gives is "Safe Links in Microsoft Defender for Office 365". Supabase's troubleshooting page for this error calls email prefetching "the most common reason for OTP tokens appearing expired or invalid before a user can even use them."

The default link has no step between opening and using. Its address is your project's verify endpoint, which checks the token and spends it on the first visit, whoever makes it. So the filter hands the ticket in as the message arrives, and it goes through. Your user clicks a minute later and is turned away at the door.

The first visit uses the link. A mail filter that opens it to check it gets there before the person does.

Two things follow from that.

  • You will not see it yourself. It happens to people whose mail passes through a filter of that kind, which may never include your own inbox. Your test sign-ups keep working while your users write in.
  • The account is usually confirmed already. For a sign-up confirmation, the filter's visit counts: Supabase confirms the address of whoever opens the link. When a filter opened it, the account is confirmed by the time your user sees the error, and they can sign in with the password they chose.

The other three causes

Each of these produces the same message, and each one needs something different from you.

The link was used already. Your user pressed it once, perhaps on their phone, and is now pressing the same email on their laptop. When a link works, Supabase clears every link still waiting for that account. Its account code runs the same cleanup after a confirmation and after a sign-in link, so a second press finds nothing. The ticket was punched on the first visit.

A newer email replaced it. Pressing "send again" creates a new link, and Supabase deletes the older one of the same kind before saving it. That happens in the code that creates the token. The first email still sits in the inbox looking fine, and its link fails. Only the newest email works. Supabase also lets one address ask for this kind of email only once every 60 seconds, which the email rate limit covers in full.

It really did expire. Supabase's passwordless guide says magic links "expire after 1 hour" by default. The same page says the Email OTP Expiration setting "also governs the validity of Magic Links and other email links, including confirmation, password recovery, email change, and invitation links." Somebody who signs up in the evening and opens the email the next morning meets this message, whatever their mail service does.

If your app uses the PKCE flow, the other device fails with a different message first, and this one follows.

PKCE splits the ticket in two. Supabase's PKCE guide says "the code verifier is created and stored locally when the Auth flow is first initiated", and so "the code exchange must be initiated on the same browser and device where the flow was started." The email carries one half. The browser where your user asked for the link keeps the stub.

Opened on a phone, the link still goes through Supabase's check, which uses it. Then your app looks for the stub on the phone and finds none. Supabase's JavaScript client reports that in its own words:

PKCE code verifier not found in storage. This can happen if the auth flow was initiated in a different browser or device, or if the storage was cleared.

So the person goes back to the laptop and presses the same email there, and Supabase answers "Email link is invalid or has expired", because the phone already had the ticket punched. If the address your app opens on after a working link contains ?code=, your app uses PKCE and this case applies to you.

How do I tell which one it was?

Ask your own database. Every account in your project is a row in a table called auth.users, and that row records when each kind of email was sent and when the address was confirmed.

Open the SQL Editor in your Supabase dashboard and run this with the address the person gave you:

select email,
       confirmation_sent_at,
       email_confirmed_at,
       recovery_sent_at
from auth.users
where lower(email) = lower('person@example.com');

It reads one row and changes nothing. confirmation_sent_at is when the last sign-up confirmation went out. recovery_sent_at is when the last magic link or password reset went to an account that already existed. Those are the times Supabase measures the hour from, in the same function that returns the error. Compare them with the time on the email the person pressed.

What you seeWhat it means
A confirmation link failed on its first press, and email_confirmed_at is filled inSomething opened the link before the person did. The account is confirmed, so they can sign in with their password
A confirmation link failed, email_confirmed_at is empty, and they pressed it over an hour after it was sentIt expired. Send a new confirmation
confirmation_sent_at or recovery_sent_at is later than the email they pressedA newer email replaced it. The newest one is the only link that works
A magic link from the newest email failed on its first press, within the hourSomething opened it first: a mail filter, or the person on another device

If the row shows the address confirmed and the person still cannot get in with a password, Invalid login credentials is the next message to read about.

What not to change while you fix it

The expiry, the Confirm email switch, and who is allowed to confirm a user. Each of the obvious shortcuts here makes the message stop and opens something.

Leave the expiry at an hour. A filter opens the link as the message arrives, so a link that lasts a week still gets used first. Supabase's passwordless guide says "An expiry duration of more than 86,400 seconds (one day) is strongly discouraged". A sign-in link signs in whoever opens it. The longer it keeps working, the longer a forwarded email, a shared inbox or an old message in somebody's archive is a way into that account.

Leave Confirm email on. Switching it off means sign-ups send no link at all, so this error stops. It also means anybody can create an account with an address they have never controlled.

Do not confirm users from the browser. Ask an assistant to "just mark them as confirmed" and it may reach for Supabase's admin functions, which can set email_confirm on an account. Supabase's admin reference says "Any method under the supabase.auth.admin namespace requires a secret key" and "Never expose your secret key in the browser." A secret key in your frontend reads and writes every table in your database, whatever your rules say, for anyone who opens your site's code. That is the key this article on API keys tells you to rotate. If you are not sure one has slipped in, our free scan reads the code your live app sends to every visitor and flags the secret keys it finds there. It takes about 20 seconds and needs no account: scan your app.

Check any address your confirm page passes on. Supabase's second suggested fix is a page of your own with a button, reached through a link of this shape:

<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}">

The button then goes wherever confirmation_url says. Built without a check, that page will send anybody who follows a crafted link to any address at all, under your app's name. Either check that the address starts with your own project's https://<project-ref>.supabase.co/auth/v1/verify, or use the token version in the next section, which passes no address through the page.

Make opening the address do nothing, so that only a person typing a code or pressing a button signs in. Supabase documents both, and you change them in the email templates under Authentication in your dashboard. Confirm signup and Magic Link are the two to edit.

Send a code. Put {{ .Token }} in the template. Supabase's guide describes it as "a 6-digit One-Time-Password (OTP) that can be used instead of" the link. Your page asks for the email address and the code, then calls:

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

A filter can open an email. It cannot type a code into your page.

Link to a page with a button. Point the link at a page of yours and carry the token's hash, so no address has to pass through:

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

{{ .SiteURL }} is the Site URL on your project's URL Configuration page. If it still says http://localhost:3000, fix that first.

The page shows one button, and only the button does the work:

const params = new URLSearchParams(window.location.search)

async function onConfirmClick() {
  const { error } = await supabase.auth.verifyOtp({
    token_hash: params.get('token_hash'),
    type: 'email',
  })
  if (error?.code === 'otp_expired') showSendNewLink()
}

The type email covers both templates: Supabase's check looks the hash up among sign-up confirmations and sign-in links alike.

The page must not call verifyOtp when it loads. Supabase's password guide builds its confirmation link as {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }} and verifies it the moment that address is opened. That is the default link again at a new address, and a filter opens it just as easily.

With the default link, opening it signs in. With a button or a typed code, only the person's action does.

Whatever you change, some people will still meet an old link, so give them a way forward. The page your links land on can read the error and offer a new email:

const hash = new URLSearchParams(window.location.hash.slice(1))
if (hash.get('error_code') === 'otp_expired') showSendNewLink()

For a sign-up, add a line saying they may already be confirmed and can try signing in. If a builder writes your sign-in pages, ask it for the code or button version of both templates, a landing page that reads otp_expired and offers a fresh link, and no admin key anywhere in the app.

Hearing about trouble before your users write in

This error reaches your users and never you, and other failures reach them first too: the app going down at night, or a later prompt shipping a key in your app's code.

Reeve Monitor keeps checking your live app from outside:

  • whether it is up, every 60 seconds, so "the link does not work" can be told apart from "the site is down"
  • all nine checks every hour, on up to three apps, including secret keys in the code your app sends to visitors
  • a message when a result changes, so a key that ships in a later deploy reaches you without you going to look
  • a monthly report, in plain language, of what it saw

Reeve Care keeps a copy of your Supabase database, accounts included:

  • an encrypted copy on a schedule, on Reeve's own storage in the EU, outside your Supabase account
  • the auth schema your users' accounts live in, so they come back with everything else
  • each copy verified before it counts, by counting the rows in every table
  • your uploaded files as well, once you connect a Storage credential
  • a one-click restore, with a safety copy of the current state taken first
  • everything Monitor does

What to do now

What to do

  • Read the person's row in auth.users. A filled email_confirmed_at on a failed confirmation means a filter opened the link and they can sign in.
  • Tell people to use the newest email, and if your app uses PKCE, to open it on the device where they asked for it.
  • Change the Confirm signup and Magic Link templates to send a code, or to open a page where a button calls verifyOtp.
  • Make the page your links land on read error_code=otp_expired and offer a new link.
  • Leave the expiry at an hour, leave Confirm email on, and keep the secret key out of the browser.

If the emails are not arriving at all, the email rate limit is the first thing to rule out. The 10-minute security checklist covers what else tends to be left open in a newly launched app.

FAQ

Why does a Supabase magic link say it has expired right after I sent it?

Usually because something opened it before the person did. Some email services open the links in an incoming message to check them, and Supabase names Safe Links in Microsoft Defender for Office 365 as an example. Opening a Supabase email link uses it, so when the person clicks a minute later the token is gone and Supabase answers "Email link is invalid or has expired".

Is the account confirmed even though the link said it had expired?

If a mail filter opened a sign-up confirmation link first, yes. Supabase confirms the address for whoever opens the link, so the filter's visit confirmed it. Look at email_confirmed_at in auth.users: if it is filled in, the person can sign in with the password they chose.

How long does a Supabase email link stay valid?

One hour by default. The Email OTP expiration setting controls it, and the same setting covers magic links, sign-up confirmations, password resets, email changes and invitations. Supabase strongly discourages anything over 86,400 seconds, which is one day.

Should I make the links last longer?

No. A mail filter opens the link when the message arrives, so a longer life does not stop it being used first. What it does is keep every unused link in every inbox working for longer, and a link signs in whoever opens it, including somebody the email was forwarded to.

Why does the link work on my computer and fail on my phone?

If your app uses the PKCE flow, the browser where the person asked for the link keeps half of what the sign-in needs, so opening the email on another device fails with "PKCE code verifier not found in storage". That attempt also uses the link up, which is why clicking it again on the first device then says "Email link is invalid or has expired".

What does error_code=otp_expired in my app's address mean?

It is how Supabase Auth reports a failed email link. It sends the person on to your app with error=access_denied, error_code=otp_expired and the message in the part of the address after the # sign. Your app can read error_code there and show a page that offers to send a new link.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.