# OTP or magic link "token has expired" / otp_expired

> Email links fail with otp_expired or "Email link is invalid or has expired". Often an email scanner used the link first. How to make sign-in links reliable, with the one-time code fallback.

Source: https://supahost.app/kb/otp-expired · Updated 2026-11-11

## What you see

The user clicks the link in a sign-up, magic link or reset email and lands on your site with an error in the URL:

```
https://recipes.example.com/#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired
```

Or `supabase.auth.verifyOtp()` fails with:

```
AuthApiError: Token has expired or is invalid
```

## Why it happens

Every link and code in an Auth email works once and only for a limited time. The error means the token was already used, was replaced, or is too old:

- **An email scanner opened it first.** Outlook Safe Links, company mail gateways and some antivirus tools open every link in an email to check it. That visit uses up the one-time token before the user clicks.
- **The user asked twice.** Requesting a new email replaces the token, so only the newest email works.
- **The link was opened twice**, for example on a phone and then on a laptop, or by a double click.
- **It's too old.** The user opened yesterday's email.
- **The wrong `type` was passed** to `verifyOtp()` for that kind of email.

Opening a link in a different browser from the one that requested it gives a different error. See [PKCE errors](/kb/pkce-flow-errors).

## How to fix it

1. **Offer the one-time code.** Every Auth email SupaHost sends includes a code under the button. Scanners can't use a code, and it works in any browser. Add a "Have a code?" step:

   ```ts
   const { data, error } = await supabase.auth.verifyOtp({
     email: "cook@recipes.example",
     token: codeFromInput.trim(),
     type: "email",
   });
   ```

   Use `type: "email"` for sign-in and sign-up codes, `"recovery"` for password resets, `"invite"` for invites and `"email_change"` for address changes.

2. **Handle the error in the URL.** Read it on the page users land on, and offer to send a new email instead of showing a broken page:

   ```ts
   const params = new URLSearchParams(window.location.hash.slice(1));
   if (params.get("error_code") === "otp_expired") {
     showMessage("That link has expired. Enter your email and we'll send a new one.");
   }
   ```

3. **Send one email per request.** Disable the submit button after the first click so users don't receive two emails and open the older one.

4. **Tell users to use the newest email.** A line in the message helps, for example "Only the most recent link works." Edit it under Dashboard → your project → **Auth** tab → **Auth emails**.

5. **For company users, ask IT to exclude your sign-in links from link scanning**, or point those users to the code.

## On SupaHost

- The button link in Auth emails is added automatically and can't be rewritten, so the one-time code (step 1) is the way around link scanners.
- Link and code lifetime isn't a setting on the **Auth** tab. Ask users to open the email soon after requesting it.
- Requesting a new email too soon fails with a rate limit error. See [Not receiving Auth emails](/kb/not-receiving-auth-emails).

## Related

- [Magic links and email OTP](/docs/auth/magic-links-otp)
- [PKCE errors: code verifier, cannot parse response](/kb/pkce-flow-errors)
- [Not receiving Auth emails](/kb/not-receiving-auth-emails)
- [Auth emails and templates](/docs/auth/email-templates)
