← All posts
·7 min read·GuardLayer

Fix: Email link is invalid or has expired (otp_expired)

SupabaseAuthMagic LinkEmailNext.js

403: Email link is invalid or has expired with error_code=otp_expired usually does not mean the link expired. Supabase magic links are single-use, and corporate mail security scanners open every link in an inbound email to check it — consuming the token seconds after delivery, before your user ever clicks. The documented fix is to send a six-digit code ({{ .Token }}) or a {{ .TokenHash }} link to your own /auth/confirm route instead of a link the scanner can burn.

The report always arrives the same way: "I clicked it immediately and it says expired." The user isn't wrong, and neither is the error. Something already used that token.

Discussion #28193 has the answer straight from a Supabase collaborator — the cause is "email prefetching happening by your email client." Supabase has since written it up as an official troubleshooting page, which names prefetching as the primary cause:

"These automated prefetching services can consume the OTP token by accessing the link before the legitimate user does."

It recurs across repos and years because nothing in the error text points at email infrastructure.

Why does my Supabase magic link say it has expired when I just clicked it?

Because a machine clicked it first. Outlook Safe Links, Mimecast, Proofpoint, Barracuda and similar gateways fetch every URL in an inbound message to check it for malware. A Supabase confirmation URL is a single-use credential: fetching it is redeeming it. By the time the human clicks, the token is spent, and Supabase correctly returns otp_expired.

Four causes, and the first covers most production reports:

1. A mail scanner prefetched the link. The tell is the audience. Users on Gmail and iCloud are fine; users on a corporate domain, a school domain, or anything behind a security gateway fail consistently. If your error rate correlates with the recipient's employer rather than with time, stop looking at expiry settings.

2. The link genuinely aged out. Supabase's default expiry for email OTPs and magic links is one hour. If your emails take twenty minutes to deliver — a misconfigured custom SMTP provider, a greylisting relay — an hour is tighter than it sounds.

3. The link was opened in a different browser than the one that requested it. Under the PKCE flow the code verifier lives in the browser that started the flow. Request the link on your laptop, open the email on your phone, and the exchange has nothing to verify against. That surfaces as a broken link even though the token was fine, and it's a close relative of the invalid flow state error.

4. It was already used. A refresh of the callback page, a second click, a forwarded email. Single-use means single-use.

Supabase's troubleshooting page lists one more, and it's worth keeping in your back pocket because nothing else explains it: clock skew. If the user's device clock is meaningfully out of step with the server's, a token still inside its window can read as expired. Rare, but it's the answer when a single user fails consistently on a flow that works for everyone else.

Fix 1: send a code, not a link

This is the option Supabase lists first, and it is immune to prefetching — a scanner can fetch a URL, but it can't type a code into your form.

Change the email template (Authentication → Email Templates → Magic Link) to use {{ .Token }}:

<h2>Your sign-in code</h2>
<p>Enter this code to finish signing in:</p>
<p style="font-size:24px;letter-spacing:4px"><strong>{{ .Token }}</strong></p>
<p>It expires in one hour and can only be used once.</p>

Then verify it server-side:

// app/auth/verify/actions.ts
"use server";

import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export async function verifyCode(formData: FormData) {
  const email = String(formData.get("email"));
  const token = String(formData.get("token"));

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

  if (error) redirect("/login?error=invalid_code");
  redirect("/dashboard");
}

The trade-off is one extra screen. In exchange, the flow works from any device, in any inbox, behind any gateway — including the case where the user reads email on their phone and wants to sign in on their laptop, which the link flow cannot handle at all.

Fix 2: keep the link, move the token exchange

If you want to keep one-click sign-in, send a {{ .TokenHash }} link pointing at your own route, and redeem it there:

<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">
  Sign in
</a>
// app/auth/confirm/route.ts
import { NextResponse, type NextRequest } from "next/server";
import type { EmailOtpType } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const tokenHash = searchParams.get("token_hash");
  const type = searchParams.get("type") as EmailOtpType | null;

  if (!tokenHash || !type) {
    return NextResponse.redirect(new URL("/login?error=bad_link", origin));
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.verifyOtp({
    token_hash: tokenHash,
    type,
  });

  if (error) {
    return NextResponse.redirect(new URL("/login?error=link_expired", origin));
  }

  // Only ever redirect to a path on your own origin.
  return NextResponse.redirect(new URL("/dashboard", origin));
}

Note the redirect: a fixed path resolved against origin, not a destination read out of the query string. A callback route that redirects to a user-supplied URL is an open redirect sitting on the one path where users are least suspicious and a fresh session is in flight — the same trap described in the PKCE code-verifier post.

This alone does not stop a scanner from fetching the URL, so Supabase's docs also describe the belt-and-braces version: link to an interstitial page that carries {{ .ConfirmationURL }} and only navigates to it when a human presses a button.

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

A GET from a scanner renders the page and touches nothing. The token is redeemed on the click.

The knob not to turn

The obvious move — raise the expiry until the complaints stop — treats the wrong variable. If a scanner is consuming the token four seconds after delivery, a 24-hour expiry changes nothing.

It also makes things worse. Supabase's docs put the ceiling plainly: an expiry longer than 86,400 seconds is "strongly discouraged and can only be set via the Management API." Every hour you add is another hour that a link sitting in an unattended inbox, a forwarded thread, or a shared support mailbox is a live credential for that account. Supabase's own security advisor flags long OTP expiries for exactly this reason, in the same family of defaults as leaked-password protection. If anything, tighten it: fifteen minutes is comfortable for a code the user is typing right now.

Quick self-check

  1. Look at which users fail. Segment by email domain. Corporate domains failing while consumer domains succeed is the scanner signature, full stop.
  2. Send yourself a magic link and inspect the raw HTML source of the email. If the href has been rewritten to safelinks.protection.outlook.com or a similar wrapper, a gateway is in the path.
  3. Check delivery latency. Authentication → Emails, or your SMTP provider's log. Minutes of delay against a one-hour expiry is fine; twenty minutes against a fifteen-minute expiry is not.
  4. Confirm your expiry setting is measured in minutes, not days. If someone raised it to buy quiet, that's a finding on its own.

FAQ

Does this affect signup confirmation links too? Yes. Any single-use email link — signup confirmation, password recovery, email-change confirmation, invites — is consumable by a prefetching scanner. The {{ .TokenHash }} or {{ .Token }} pattern applies to each of those templates.

Why does it only happen in production? Development mail usually goes to Inbucket or a personal inbox with no security gateway in front of it. Production mail goes to real corporate inboxes. Same code, different mail infrastructure.

Will disabling PKCE fix it? It fixes the different-browser case (#3) and nothing else, at the cost of moving tokens into the URL fragment. Not a trade worth making.

Is otp_expired the same as a JWT expiring? No. otp_expired is a one-time email token being invalid or spent. JWT expired / PGRST301 is an already-issued access token aging out, which is a session-refresh problem.

Can I detect this from my logs? Partly. Supabase's Auth logs show the verification attempt. If you see a successful token redemption at the delivery timestamp and a failed one a minute later from a different IP and user agent, you've caught a scanner in the act.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.