add passwordless magic-link authentication with better-auth: issue, email, verify, session. trigger on "add login", "passwordless auth", "magic link", or "let users sign in".
---
description: add passwordless magic-link auth with better-auth. issue, email, verify, session. trigger on "add login", "passwordless auth", "magic link".
---
# magic link auth
passwordless login without writing your own token crypto. uses the verified better-auth module.
## intent
take a builder from "let users sign in" to a working passwordless flow: request link, email it, verify, create session. the failure mode this prevents: a hand-rolled token scheme with predictable tokens, no expiry, or no single-use guarantee.
## inputs
- a database better-auth can use (postgres, sqlite, etc)
- an email sender (pair with the transactional-email skill + resend)
- BETTER_AUTH_SECRET in env
## procedure
### step 1, configure the server
```js
const { betterAuth } = require('better-auth');
const { magicLink } = require('better-auth/plugins');
const auth = betterAuth({
database: db,
plugins: [magicLink({ sendMagicLink: async ({ email, url }) => sendEmail(email, url) })],
});
```
### step 2, request a link
the client posts an email, the plugin generates a single-use, expiring token and calls your sender.
### step 3, verify on click
the verify route validates the token, marks it used, and issues a session. the library owns single-use + expiry so you do not.
### step 4, gate routes on the session
read the session server-side on protected routes, never trust a client claim.
## decision points
- session length: short for sensitive apps, long for low-risk tools.
- email deliverability: a magic link in spam is a broken login, warm the sending domain.
- rate limit the request route or it becomes an email-bomb vector.
## output contract
a request-link route, a verify route that issues a session, single-use + expiring tokens owned by the library, and server-side session checks on protected routes.
## outcome signal
success means a user logs in end to end from one emailed link, the same link fails on second use, and an expired link is rejected. if a token is reusable or never expires, the flow is wrong.
don't have the plugin yet? install it then click "run inline in claude" again.
clarified inputs (env var setup, email provider, app url), expanded procedure with explicit code blocks and edge cases (email sender failures, token reuse, session expiry), added decision points for session lifetime and rate limiting, documented output contract format and location, and added comprehensive outcome signal test cases.
passwordless login without writing your own token crypto. uses the verified better-auth module.
take a builder from "let users sign in" to a working passwordless flow: request link, email it, verify, create session. the failure mode this prevents: a hand-rolled token scheme with predictable tokens, no expiry, or no single-use guarantee. use this when you need frictionless auth without password management overhead.
openssl rand -base64 32 or similar. store in .env or secrets manager, never commit.set up better-auth with the magic-link plugin and a sendEmail callback that connects to your email sender.
const { betterAuth } = require('better-auth');
const { magicLink } = require('better-auth/plugins');
const auth = betterAuth({
database: db,
secret: process.env.BETTER_AUTH_SECRET,
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: 'Your magic sign-in link',
html: `<a href="${url}">Sign in to your account</a>. Link expires in 15 minutes.`,
});
},
}),
],
});
input: database connection, sendEmail function, BETTER_AUTH_SECRET env var. output: auth instance with magic-link plugin ready to handle requests.
create an http endpoint (post /auth/magic-link/request) that accepts an email, validates it, and calls the plugin to generate and send a token.
app.post('/auth/magic-link/request', async (req, res) => {
const { email } = req.body;
await auth.magicLink.sendLink(email);
res.json({ message: 'check your email' });
});
input: email address from client request body. output: http response (no sensitive data). the plugin sends the token via email. edge case: if email does not exist in the user table, decide whether to return the same response (prevent user enumeration) or tell the client to sign up first. edge case: if the email sender fails (network timeout, rate limit, bad credentials), catch and return a 5xx or retry.
create an http endpoint (get /auth/magic-link/verify) that accepts a token (from the email link), validates it, marks it as used, and returns a session cookie.
app.get('/auth/magic-link/verify', async (req, res) => {
const { token } = req.query;
const result = await auth.magicLink.verifyLink(token);
if (result.success) {
res.setHeader('set-cookie', result.sessionCookie);
res.json({ sessionId: result.sessionId });
} else {
res.status(401).json({ error: 'invalid or expired token' });
}
});
input: token from query string (passed by email link). output: http response with session cookie and session id. the plugin guarantees single-use and expiry checks. edge case: if token is expired, the plugin returns error. if token was already used, the plugin returns error. if token format is invalid, the plugin returns error. edge case: if database write fails during "mark token as used", the request may timeout or return 5xx.
on routes that require auth, read the session cookie server-side, validate it against the database, and reject requests without a valid session.
app.get('/dashboard', async (req, res) => {
const session = await auth.getSession({ headers: req.headers });
if (!session) {
return res.status(401).json({ error: 'unauthorized' });
}
res.json({ user: session.user });
});
input: session cookie from request headers. output: user data if session is valid, 401 if not. edge case: if session is expired, the plugin returns null. if session was tampered with, signature validation fails. never trust a client claim of session id.
success state: a passwordless flow that issues single-use, time-bound tokens via email, verifies them, and creates a session on valid click. the verify endpoint returns a session cookie and session id. protected routes read the session server-side and reject unauthorized requests.
data format: tokens are opaque strings generated by better-auth. sessions are stored in the database with a sessionId, userId, expiresAt, and optional metadata. session cookies are httpOnly (recommended) and signed.
file/location: session data is persisted in the configured database. tokens are not persisted (single-use, verified on click, then discarded). logs should record request timestamps and email addresses for audit.
success: a user requests a link with their email, receives it within 30 seconds, clicks it, and is logged in. the same link fails on a second click with "invalid or expired token". an expired link (after 15 minutes) is rejected. a protected route returns 401 if no valid session cookie is present. if a token is reusable, expires after days instead of minutes, or a second click succeeds, the flow is broken. test end to end with a real email account and a clock set to verify expiry.