Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Supabase Magic Links: Passwordless Login and the Four Ways It Fails

Sean

Platform Writer

Aug 20, 2026
8 min read

signInWithOtp sends a one-time link to an email address, and clicking it creates a session. Email authentication is on by default, so the implementation is one function call — and every real problem with magic links happens after the send.

Supabase Magic Links: Passwordless Login and the Four Ways It Fails

Passwordless login is a genuinely good trade for most consumer applications. You stop storing password hashes, you stop handling reset flows, and users stop reusing the password they use everywhere else. In exchange you take a hard dependency on email delivery, which is a category of problem most developers have not had to think about before.

The call is easy. This is mostly about the four things that go wrong afterwards.

Table of contents

The implementation

Sending the link:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

async function signIn(email) {
  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: {
      emailRedirectTo: 'https://example.com/auth/callback',
      shouldCreateUser: true
    }
  });

  if (error) {
    console.error(error.message);
    return;
  }

  // Always show the same message, whether or not the address exists
  showMessage('If that address is registered, a sign-in link is on its way.');
}

Two options carry more weight than they look.

shouldCreateUser decides whether an unknown address becomes a new account or is rejected. Setting it to true gives you signup and login as one flow, which is elegant. Setting it to false for an application where accounts are provisioned by an admin prevents anyone from creating an account by typing an address.

emailRedirectTo must be on your allowlist of redirect URLs, configured in the project settings. A URL that is not listed is silently replaced with the site default, which is a confusing debugging session — the link works, it just sends people to the wrong place.

And the message shown to the user should not reveal whether the address exists. A response that differs between known and unknown addresses turns your login form into an account-enumeration endpoint.

The callback, and why the session sometimes does not appear

The link lands on your callback route with a token in the URL, and that token has to be exchanged for a session.

// /auth/callback
const params = new URLSearchParams(window.location.search);
const token_hash = params.get('token_hash');
const type = params.get('type');

if (token_hash && type) {
  const { error } = await supabase.auth.verifyOtp({ token_hash, type });

  if (error) {
    redirect('/login?error=link_expired');
  } else {
    redirect('/dashboard');
  }
}

The most common confusion here is a cross-device one. The link is clicked wherever the email is read, which is frequently a phone when the login was started on a laptop. The session is created on the device that opened the link, and the laptop sits there indefinitely waiting for something that already happened somewhere else.

This is not a bug — it is inherent to the mechanism — but it surprises users badly. Two mitigations are worth having:

  • Tell them where to look. After sending, say plainly that clicking the link signs them in on whichever device opens it.
  • Offer a code as well as a link. A six-digit one-time code can be typed into the original device, which keeps the session on the machine where the user started. For anything where the login device matters, this is the better default.

For a server-rendered application, run the exchange on the server and set an HTTP-only cookie, rather than doing it in client JavaScript where the token passes through the browser’s history.

This is the failure that generates the most confused bug reports, and it is not your code.

Corporate email security scans links in inbound mail. It does this by fetching them. A one-time link that gets fetched is a one-time link that has been used, so by the time the human clicks, the token is already consumed and they see an error about an expired or invalid link.

The pattern is diagnostic: it works fine for personal addresses and fails consistently for one company’s domain. That is a scanner, and no amount of debugging your callback will find it.

What actually helps:

  • Send a code instead of a link for affected users. A six-digit code cannot be consumed by a scanner that follows URLs.
  • Make the link land on a page with a button rather than authenticating on load. A scanner following the URL sees a page; the token is exchanged only when a human clicks. This costs one extra click and solves most of it.
  • Do not shorten the URL or wrap it in click tracking. Both make links look more suspicious to scanners and to spam filters.

The second option is the most robust and the least discussed. Authenticating on page load feels smoother and is exactly what makes the link fragile.

Deliverability is the real dependency

With magic links, email delivery is not a notification channel — it is the authentication system. If mail does not arrive, nobody can log in. That reframing changes how much care the email configuration deserves.

The built-in email service exists for development and has strict rate limits that are not intended for production traffic. Configure a real SMTP provider before launch, not after the first complaint.

Then configure the DNS records that decide whether your mail is trusted:

  • SPF — a TXT record naming the servers allowed to send as your domain.
  • DKIM — a signing key so recipients can verify the message was not altered.
  • DMARC — a policy telling receivers what to do when SPF or DKIM fails, plus a reporting address so you find out.

Without these, a meaningful share of your login emails go to spam, and the user experience is a login form that silently does nothing. Send from a subdomain used only for transactional mail, so that marketing sends cannot damage the reputation your authentication depends on.

Monitor delivery the way you would monitor an API. A drop in delivery rate is an outage; it just does not look like one on a dashboard that only watches HTTP status codes.

Rate limits, expiry, and abuse

A public endpoint that sends email on demand is a public endpoint that sends email on demand. Two things follow.

Someone will use it to harass an address. Repeated requests for the same email address should be throttled server-side, not only in the UI, since the UI is not where an attacker is.

Someone will use it to burn your sending quota. A script requesting links for thousands of addresses costs you money and damages your sender reputation, which affects every user.

Configure short expiry — a link valid for an hour is a link that stays valid in an inbox for an hour, and inboxes get compromised. Fifteen minutes is a reasonable default for most applications, and the shorter window also limits the damage from a forwarded email.

Two more defaults worth setting deliberately:

  • Single use. A token that works twice works for anyone who reads the mailbox later.
  • Invalidate on use. Requesting a new link should invalidate the previous one, so a user who clicks resend three times does not leave three valid credentials in their inbox.

And keep in mind what the security model actually is: with magic links, whoever controls the email account controls the account. That is usually acceptable, because password reset flows have always had the same property. It is worth stating explicitly if you are handling anything sensitive, where a second factor stops being optional.

How this fits the rest of the stack

Magic links move the hard part of authentication out of your database and into your email pipeline. That is a good trade for most products, provided you treat delivery as infrastructure rather than as a setting — real SMTP, correct DNS records, monitored delivery rates, and a fallback code for the users whose mail systems eat links.

When the auth service and the application are separate things, what you still need is somewhere reliable for the application itself to run. RunxBuild deploys a web service in Node, Next.js, Python, Go, Ruby, Java, .NET or Docker from your GitHub repository, with environment variables kept out of the client bundle, runtime logs that show the callback requests as they arrive, and rollback to the previous deploy when a release goes wrong. Managed MySQL and Postgres sit beside it on private networking. To see what the service and the database come to together, the RunxBuild hosting calculator lists them as separate line items.

Useful related references:

FAQ

How do I send a magic link with Supabase?

Call supabase.auth.signInWithOtp({ email, options: { emailRedirectTo } }). Email authentication including magic links is enabled by default, so no extra configuration is needed to start. The redirect URL must be on the project’s allowlist, or it is silently replaced with the site default.

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

Most often a corporate email security scanner fetched the URL before the user did, consuming the one-time token. The tell is that it fails consistently for one company’s domain and works everywhere else. Make the link land on a page with a button rather than authenticating on load, or offer a numeric code instead.

Why does the user stay logged out on the device where they started?

Because the session is created on whichever device opened the link, and email is often read on a phone when the login began on a laptop. This is inherent to the mechanism. Say so in the interface after sending, and offer a six-digit code that can be typed into the original device when the login device matters.

Can I use the built-in email service in production?

No. It is intended for development and carries rate limits that will not support real traffic. Configure a proper SMTP provider, then set up SPF, DKIM and DMARC records — without them a meaningful share of login emails go to spam, and with magic links that means users simply cannot sign in.

How long should a magic link stay valid?

Around fifteen minutes for most applications. An hour-long link sits valid in an inbox for an hour, and inboxes get compromised or forwarded. Make tokens single-use, and invalidate the previous link when a new one is requested so a user who clicks resend does not leave several working credentials behind.

#supabase magic links#supabase#authentication#passwordless#email