Magic links in 10 minutes

Short-lived signed URLs over email. Less UX friction than passwords, half the code.

· LoginWith team

Magic links are the simplest passwordless auth mechanism and one of the best for low-friction B2C. Implementation is shorter than email/password.

The flow

  1. User enters their email.
  2. You generate a short-lived, one-time token.
  3. You email them a link: https://yourapp.com/magic/{token}.
  4. They click it. Your server verifies the token, creates a session, and redirects them to the app.

No passwords, no reset flow, no credential storage. The user’s email is the credential.

The schema

CREATE TABLE magic_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token TEXT NOT NULL UNIQUE,
  email TEXT NOT NULL,
  user_id UUID REFERENCES users(id), -- NULL if signup
  expires_at TIMESTAMPTZ NOT NULL,
  used_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON magic_tokens (token);
CREATE INDEX ON magic_tokens (expires_at); -- for cleanup

The endpoints

POST /magic/request

const email = normalize(req.body.email)
const token = crypto.randomBytes(32).toString('base64url')
const user = await findOrCreateUser(email)  // or just find for sign-in-only
await db.magic_tokens.insert({
  token,
  email,
  user_id: user?.id,
  expires_at: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
})
await sendEmail({
  to: email,
  subject: 'Sign in to YourApp',
  body: `Click here to sign in: https://yourapp.com/magic/${token}`
})
return res.json({ ok: true })  // always success — prevent enumeration

GET /magic/:token

const { token } = req.params
const record = await db.magic_tokens.find({ token })
if (!record || record.used_at || record.expires_at < new Date()) {
  return res.redirect('/login?error=invalid_link')
}
await db.magic_tokens.update(record.id, { used_at: new Date() })
const user = record.user_id
  ? await db.users.find(record.user_id)
  : await db.users.create({ email: record.email })
await createSession(res, user)
res.redirect('/app')

That’s the whole thing. Less than 30 lines of meaningful code.

The gotchas

  • Token length: 32 bytes of randomness minimum. Shorter tokens are brute-forceable.
  • Single-use: mark used_at on first click. A second click on the same link fails. Otherwise, forwarded email becomes a session transfer.
  • Expiration: 15 minutes is a good default. Longer than that, the risk window on a leaked email grows.
  • Rate limit the request endpoint: don’t let attackers flood a user’s inbox. 3 requests per 5 minutes per email.
  • Handle URL prefetching: some email clients prefetch URLs, triggering the token. Solution: require a POST to actually activate, or accept that prefetch happens and design for it.

Comparison to email/password

  • Magic links have less code, no password storage, no reset flow
  • They work as the reset flow for themselves — “sign in again” effectively resets
  • UX is smoother for monthly-active users (no password to remember)
  • UX is worse for daily-active users (you don’t want to get an email every day)

For most B2C and many B2B apps, magic links alongside (or instead of) passwords is the right call.

Want auth that just works?

Get started with LoginWith