Passwordless auth still needs email. Magic links and one-time passcodes (OTPs) are the same job with different UX:
- Create a short-lived secret in your backend
- Store a hash with expiry
- Email the raw secret (link or code)
- Verify once, then invalidate
Notify delivers the message. You own tokens and sessions. No template studio required — build the HTML yourself and send it with one HTTPS call.
Shared security rules
| Rule | Magic link | OTP |
|---|---|---|
| Random secret | 32+ bytes hex/UUID | 6-digit from CSPRNG |
| Store hash | Yes | Yes |
| TTL | 5–15 minutes | 5–10 minutes |
| Single-use | Yes | Yes |
| Rate limit | Per email + IP | Per email + IP |
| HTTPS only | Link must be HTTPS | Form POST over HTTPS |
Never email passwords. Never log raw tokens in plaintext production logs.
Prerequisites
- Database or cache for hashed tokens
NOTIFY_API_KEYon the server (credentials)- Verified domain for production
from(domain verification)
NOTIFY_API_KEY=your_api_key_here
APP_URL=https://yourapp.com
Until DNS is ready, rehearse with POST https://notify.cx/api/email/send/test. See sandbox vs production.
Rate limiting
Same policy as password reset: throttle by IP and by email so attackers can’t flood inboxes. A simple in-memory limiter:
// lib/rate-limit.ts
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
export function allowRequest(key: string, limit = 5, windowMs = 15 * 60 * 1000) {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now > bucket.resetAt) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (bucket.count >= limit) return false;
bucket.count += 1;
return true;
}
Swap for Redis in multi-instance production. The important part is the policy, not the store.
Helper: send with Notify
// lib/email.ts
export async function sendEmail(opts: {
to: string;
subject: string;
message: string;
}) {
const apiKey = process.env.NOTIFY_API_KEY;
if (!apiKey) throw new Error('NOTIFY_API_KEY is not set');
const res = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify({
from: 'noreply@your-verified-domain.com',
...opts
})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
Stub token store
// lib/db/email-tokens.ts
// Wire these to your ORM / SQL / Redis.
export type EmailTokenType = 'verify' | 'magic' | 'otp';
export async function createEmailToken(args: {
email: string;
userId?: string;
type: EmailTokenType;
tokenHash: string;
expiresAt: Date;
}): Promise<{ id: string }> {
// INSERT into email_tokens …
throw new Error('Implement createEmailToken');
}
export async function findValidToken(
tokenHash: string,
type: EmailTokenType
): Promise<{ id: string; email: string; userId?: string } | null> {
// SELECT where hash matches, type matches, unused, not expired
throw new Error('Implement findValidToken');
}
export async function markTokenUsed(id: string): Promise<void> {
throw new Error('Implement markTokenUsed');
}
export async function findOrCreateUserByEmail(
email: string
): Promise<{ id: string }> {
throw new Error('Implement findOrCreateUserByEmail');
}
export async function createSession(userId: string): Promise<void> {
// Set cookie / issue JWT / insert session row
throw new Error('Implement createSession');
}
Hash tokens with createHash('sha256') from Node’s crypto module at the call site (see examples below).
Email verification
import { createHash, randomBytes } from 'crypto';
import { sendEmail } from '@/lib/email';
import { createEmailToken } from '@/lib/db/email-tokens';
async function sendVerificationEmail(user: { id: string; email: string }) {
const token = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(token).digest('hex');
await createEmailToken({
userId: user.id,
email: user.email,
type: 'verify',
tokenHash,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24)
});
const verifyUrl = `${process.env.APP_URL}/verify-email?token=${token}`;
await sendEmail({
to: user.email,
subject: 'Verify your email',
message: `
<p>Confirm your email:</p>
<p><a href="${verifyUrl}">Verify email</a></p>
<p>This link expires in 24 hours.</p>
`
});
}
Magic link (passwordless sign-in)
Same as verification, but the callback creates a session instead of flipping email_verified.
import { createHash, randomBytes } from 'crypto';
import { sendEmail } from '@/lib/email';
import { allowRequest } from '@/lib/rate-limit';
import { createEmailToken } from '@/lib/db/email-tokens';
async function sendMagicLink(email: string, ip: string) {
const normalized = email.trim().toLowerCase();
if (!allowRequest(`magic:ip:${ip}`) || !allowRequest(`magic:email:${normalized}`)) {
return; // caller still returns a generic “check your inbox” response
}
const token = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(token).digest('hex');
await createEmailToken({
email: normalized,
type: 'magic',
tokenHash,
expiresAt: new Date(Date.now() + 1000 * 60 * 15)
});
const magicUrl = `${process.env.APP_URL}/auth/magic?token=${token}`;
await sendEmail({
to: normalized,
subject: 'Your sign-in link',
message: `
<p><a href="${magicUrl}">Sign in to Your App</a></p>
<p>Expires in 15 minutes. If you did not request this, ignore this email.</p>
`
});
}
Callback:
// app/auth/magic/route.ts
import { createHash } from 'crypto';
import {
findValidToken,
markTokenUsed,
findOrCreateUserByEmail,
createSession
} from '@/lib/db/email-tokens';
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get('token');
if (!token) return new Response('Missing token', { status: 400 });
const tokenHash = createHash('sha256').update(token).digest('hex');
const row = await findValidToken(tokenHash, 'magic');
if (!row) return new Response('Invalid or expired', { status: 400 });
await markTokenUsed(row.id);
const user = await findOrCreateUserByEmail(row.email);
await createSession(user.id);
return Response.redirect(`${process.env.APP_URL}/dashboard`);
}
OTP / one-time code
Prefer numeric codes for mobile keyboards. Put the code in the subject for glanceable UX (many clients show subject in notifications).
import { createHash, randomInt } from 'crypto';
import { sendEmail } from '@/lib/email';
import { createEmailToken } from '@/lib/db/email-tokens';
async function sendOtp(email: string) {
const code = String(randomInt(100000, 1000000)); // 6 digits, CSPRNG
const tokenHash = createHash('sha256').update(code).digest('hex');
await createEmailToken({
email: email.trim().toLowerCase(),
type: 'otp',
tokenHash,
expiresAt: new Date(Date.now() + 1000 * 60 * 10)
});
await sendEmail({
to: email,
subject: `${code} is your verification code`,
message: `
<p>Your code is <strong style="font-size:24px;letter-spacing:4px">${code}</strong></p>
<p>It expires in 10 minutes.</p>
`
});
}
On submit: hash the user input, compare via findValidToken, enforce a small attempts limit (e.g. 5 tries), invalidate on success.
Auth.js / NextAuth with Notify (instead of Resend)
Many Auth.js examples hard-code Resend. You can keep Auth.js and swap only the send function:
import NextAuth from 'next-auth';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
{
id: 'email',
type: 'email',
async sendVerificationRequest({ identifier: email, url }) {
await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.NOTIFY_API_KEY!
},
body: JSON.stringify({
from: 'noreply@your-verified-domain.com',
to: email,
subject: 'Sign in to Your App',
message: `
<p><a href="${url}">Sign in</a></p>
<p>This link expires soon. If you did not request it, ignore this email.</p>
`
})
});
}
}
]
// adapter required for email provider token storage
});
Adjust to your Auth.js version’s email provider API. The idea is constant: Auth.js owns the token URL; Notify owns delivery.
Better Auth sendMagicLink hook
magicLink({
expiresIn: 60 * 15,
sendMagicLink: async ({ email, url }) => {
await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.NOTIFY_API_KEY!
},
body: JSON.stringify({
from: 'noreply@your-verified-domain.com',
to: email,
subject: 'Your sign-in link for YourApp',
message: `<p><a href="${url}">Sign in</a></p>`
})
});
}
});
Copy tips (deliverability-adjacent)
- One CTA link or one code — not both cluttered
- Name your product in the subject
- State expiry
- Avoid shorteners
- Send from a verified domain that matches your product brand
Bottom line
Magic links and OTPs are your auth system plus someone else’s SMTP reputation. Keep tokens in your DB. Keep keys on the server. Send with a transactional API like Notify.
Resources
Further Reading
Discover more articles on similar topics across our network
Comments
Loading comments…