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
- User enters their email.
- You generate a short-lived, one-time token.
- You email them a link:
https://yourapp.com/magic/{token}. - 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_aton 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.