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.

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.

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:

    const { data, error } = await supabase.auth.verifyOtp({
      email: "[email protected]",
      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:

    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.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us