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.

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.
What does "Email link is invalid or has expired" mean in Supabase?
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.
Why a link sent a minute ago is already used
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.
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.
What happens when the link is opened on another device?
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 see | What it means |
|---|---|
A confirmation link failed on its first press, and email_confirmed_at is filled in | Something 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 sent | It expired. Send a new confirmation |
confirmation_sent_at or recovery_sent_at is later than the email they pressed | A 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 hour | Something 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.
How to send a link a mail filter cannot use up
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.
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 filledemail_confirmed_aton 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_expiredand 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.