How to Implement Passwordless Auth Emails

Magic links and one-time codes are just a token your app generates, delivered by email, verified when the user returns. Here's the full pattern, with working code.

8 min readRyan Brown

Passwordless auth over email — magic links and one-time passcodes (OTP) — follows the same underlying pattern regardless of how it's implemented: your application generates a short-lived, single-use secret, stores a protected version of it, emails the secret (as a link or a code) to the user, and validates it when they come back. The email itself is a simple, single-recipient transactional send; the actual security logic (token generation, hashing, expiry, session creation) lives entirely in your own application.

For the purposes of this article, we'll be using Notify, a lightweight transactional email API for developers that sends email through a single endpoint, verifies sending domains, and keeps delivery logs — without a marketing platform, template builder, or bulk-sending features layered on top. That scope is exactly why it fits this specific piece: Notify handles email delivery and observability, but it doesn't issue tokens, manage sessions, or sit between your app and your auth logic. You keep control of how tokens are generated, stored, and verified, and Notify's job stops at getting the message to the inbox. Combined with a single API call, no SDK requirement, and a free tier that can cover early auth traffic, it fits this use case well.

Prerequisites

This tutorial keeps the authentication logic in your application, so you'll need a few things before starting:

  • A server-side JavaScript or TypeScript application with Node.js 18+ or another runtime that supports the Fetch API.
  • A database or other persistent store for authentication tokens. The examples use a generic db.emailTokens interface so you can adapt them to Prisma, Drizzle, Supabase, or your own database client.
  • A Notify account and API key, stored server-side as NOTIFY_API_KEY. Never expose the key in browser code or commit it to source control.
  • A secret such as AUTH_TOKEN_SECRET for protecting stored OTP values. Keep it server-side as well.
  • A verified sending domain for production email. Notify requires a verified domain for custom from addresses.

For example, your environment might contain:

NOTIFY_API_KEY=your_notify_api_key
AUTH_TOKEN_SECRET=a-long-random-server-side-secret
APP_URL=https://yourapp.com

The API key and token secret should only be available to server-side code. See Notify's authentication and API key documentation for the API-key setup and security recommendations.

If your domain is not verified yet, you can still test the API shape using Notify's non-delivering test endpoint. The Quick Start Guide covers both the guided dashboard test and the /api/email/send/test rehearsal endpoint.

The Shared Pattern

Every auth email — verification, magic link, or OTP — follows the same five steps:

  1. Generate a random token or numeric code in your backend.
  2. Store a protected version of it with a user_id/email and an expiry time. Never store a raw authentication secret unnecessarily.
  3. Build the email content — a link with the raw token embedded, or the code itself.
  4. Send it via a single API call.
  5. When the user clicks the link or submits the code, validate it against your store, then invalidate it so it can't be reused.

Notify's role is entirely step four.

For high-entropy link tokens, a SHA-256 hash is sufficient because the token itself is generated randomly and has enough entropy to make guessing impractical. For short OTP codes, use a keyed hash such as HMAC because a six-digit code has a small enough search space to be brute-forced if an attacker obtains the database.

Here is a simple helper for both cases:

import { createHash, createHmac, randomBytes } from 'node:crypto';

export function createRawToken() {
  return randomBytes(32).toString('hex');
}

export function hashToken(token: string) {
  return createHash('sha256').update(token).digest('hex');
}

export function hashOtp(code: string) {
  return createHmac(
    'sha256',
    process.env.AUTH_TOKEN_SECRET!
  ).update(code).digest('hex');
}

Your database record should also include an explicit used_at field, or an equivalent mechanism, so that successful authentication invalidates the token immediately.

Email Verification

This function generates a random token, stores a protected copy of it with a 24-hour expiry, builds a link containing the raw token, and sends it through Notify's send endpoint. When the user clicks the link, a separate route looks up the protected token, checks that it hasn't expired or already been used, marks the token used, and flips the user's account to verified.

async function sendVerificationEmail(user: { email: string; id: string }) {
  const token = createRawToken();

  await db.emailTokens.create({
    userId: user.id,
    type: 'verify',
    tokenHash: hashToken(token),
    expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
    usedAt: null
  });

  const verifyUrl = `${process.env.APP_URL}/verify-email?token=${token}`;

  const response = 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({
      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>`,
      from: 'noreply@your-verified-domain.com'
    })
  });

  if (!response.ok) {
    throw new Error(`Notify error: ${await response.text()}`);
  }
}

The database interface is intentionally generic here. db.emailTokens.create() represents whatever database client your application uses; it is not a built-in function.

The callback route completes the other half of the flow:

export async function verifyEmail(request: Request) {
  const token = new URL(request.url).searchParams.get('token');

  if (!token) {
    return new Response('Missing token', { status: 400 });
  }

  const row = await db.emailTokens.findValid({
    tokenHash: hashToken(token),
    type: 'verify'
  });

  if (!row || row.usedAt || row.expiresAt < new Date()) {
    return new Response('Invalid or expired link', { status: 400 });
  }

  await db.users.markVerified(row.userId);
  await db.emailTokens.markUsed(row.id);

  return Response.redirect(`${process.env.APP_URL}/login?verified=1`);
}

The exact database queries and routing syntax will depend on your framework and database library, but the security flow stays the same: hash the presented token, find an unused unexpired record, perform the action, and invalidate the token.

The structure is identical to verification — generate a token, protect and store it, build a URL, send it. What's different is the callback route it points to: instead of flipping a verified flag, it creates a logged-in session directly. The user never enters a password; clicking a still-valid link is what authenticates them. See Notify's magic-link guide for the full callback pattern.

async function sendMagicLink(email: string) {
  const token = createRawToken();

  await db.emailTokens.create({
    email,
    type: 'magic',
    tokenHash: hashToken(token),
    expiresAt: new Date(Date.now() + 1000 * 60 * 15),
    usedAt: null
  });

  const magicUrl = `${process.env.APP_URL}/auth/magic?token=${token}`;

  const response = 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({
      to: email,
      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>`,
      from: 'noreply@your-verified-domain.com'
    })
  });

  if (!response.ok) {
    throw new Error(`Notify error: ${await response.text()}`);
  }
}

Keep magic link tokens short-lived — 5 to 15 minutes is a reasonable window — and strictly single-use.

The callback route then turns a valid token into a session:

export async function consumeMagicLink(request: Request) {
  const token = new URL(request.url).searchParams.get('token');

  if (!token) {
    return new Response('Missing token', { status: 400 });
  }

  const row = await db.emailTokens.findValid({
    tokenHash: hashToken(token),
    type: 'magic'
  });

  if (!row || row.usedAt || row.expiresAt < new Date()) {
    return new Response('Invalid or expired link', { status: 400 });
  }

  const user = await db.users.findOrCreateByEmail(row.email);

  await db.emailTokens.markUsed(row.id);

  const session = await createSession(user.id);

  // Set the session using your framework's secure, HTTP-only
  // cookie/session mechanism.
  return setSessionCookie(session);
}

In a real application, the session should normally be established with a secure, HTTP-only cookie or your framework's equivalent rather than placing a session identifier in a redirect URL. The createSession() and setSessionCookie() calls above represent your existing session-management code.

One-Time Passcode (OTP)

Rather than embedding a token in a URL, this generates a random 6-digit number, protects and stores it the same way as the other flows, and puts the code directly in the email subject and body. The user reads the code and types it back into your app's UI, where you check it against the stored value — no link or redirect involved. Notify's docs cover this alongside the link-based flows since the underlying pattern is the same.

For mobile-friendly flows, a 6-digit numeric code is often preferable to a link:

import { randomInt } from 'node:crypto';

const code = String(randomInt(100000, 1000000));

await db.emailTokens.create({
  email,
  type: 'otp',
  tokenHash: hashOtp(code),
  expiresAt: new Date(Date.now() + 1000 * 60 * 10),
  usedAt: null
});

const response = 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({
    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>`,
    from: 'noreply@your-verified-domain.com'
  })
});

if (!response.ok) {
  throw new Error(`Notify error: ${await response.text()}`);
}

Using crypto.randomInt() instead of Math.random() matters here: authentication codes should come from a cryptographically secure random source.

Putting the code directly in the subject line helps users who only glance at their inbox without opening the message.

When the user submits the code, hash it with the same server-side secret and compare it against the stored record:

async function verifyOtp(email: string, code: string) {
  const row = await db.emailTokens.findValid({
    email,
    type: 'otp',
    tokenHash: hashOtp(code)
  });

  if (!row || row.usedAt || row.expiresAt < new Date()) {
    return { ok: false };
  }

  await db.emailTokens.markUsed(row.id);

  const user = await db.users.findOrCreateByEmail(email);
  const session = await createSession(user.id);

  return { ok: true, session };
}

In production, also limit the number of incorrect attempts for a given code and rate-limit requests for new codes. Otherwise, even a short-lived OTP can become an abuse target.

Testing Before Production

You don't need to wait for DNS verification to test the shape of your integration.

Notify provides a non-delivering test endpoint at /api/email/send/test. It accepts the basic send payload, records a TEST entry in the logs, does not deliver an email to an inbox, and does not count against your quota. The Quick Start Guide also provides a guided dashboard test that sends a real email.

For example:

const response = await fetch('https://notify.cx/api/email/send/test', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.NOTIFY_API_KEY!
  },
  body: JSON.stringify({
    to: 'you@example.com',
    from: 'noreply@your-verified-domain.com',
    subject: 'Passwordless auth test',
    message: '<p>Your email integration is working.</p>'
  })
});

console.log(await response.json());

For production sends, verify your sending domain with SPF, DKIM, and DMARC. Notify's domain verification guide walks through the DNS setup.

Deliverability and Security Notes

  • Send from a verified domain with SPF and DKIM aligned — auth email that lands in spam defeats the entire flow.
  • Keep the copy short and free of marketing language; state plainly why the email arrived ("you requested a sign-in link").
  • Rate-limit how often a given email address can request a new token, to slow abuse.
  • Always protect tokens before storing them, and enforce single-use, short expiry windows.
  • Rate-limit OTP verification attempts as well as token-generation requests.
  • Avoid revealing whether an email address has an account when handling login or password-reset requests. Return the same outward response whether or not the account exists.
  • Once volume justifies it, webhooks (available on Notify's Pro plan) let you react to bounces automatically — useful for flagging an address that can no longer receive auth mail.

Pricing for This Workload

Notify's free plan covers 1,000 emails a month with no credit card required, which can cover many early-stage auth workloads. The Pro plan is $10/month for 10,000 emails, with permanent logs and webhooks included. The Scale plan is $50/month for 100,000 emails, with everything in Pro plus support for up to 10 sending domains and 10 webhooks.

Frequently Asked Questions

What is Notify?

Notify is a lightweight transactional email API for developers. It sends email through a single endpoint, verifies sending domains (SPF/DKIM/DMARC), keeps delivery logs, and offers webhooks on Pro and Scale plans — without a marketing platform or template suite.

What's included in Notify's free plan and paid plans?

Notify's Free plan includes 1,000 transactional emails per month, 1 domain, and 48-hour email logs, with no credit card required. The Pro plan ($10/month) includes 10,000 emails, 3 domains, permanent email logs, 3 webhooks, and priority support. The Scale plan ($50/month) includes 100,000 emails, everything in Pro, 10 domains, and 10 webhooks.

Does Notify handle token generation or session management?

No — Notify only delivers the email. Token generation, hashing, expiry, and session creation are handled entirely in your own application. This keeps the security-critical parts of a passwordless flow under your direct control rather than a third party's.

Both work; the choice is mostly about user experience. Magic links are a single click and work well when the user opens the email on the same device they're signing in from. OTP codes are more mobile-friendly and don't depend on cross-device link handling, since the user just types the code back into the app.

Short windows are safer — 5 to 15 minutes for magic links, and around 10 minutes for OTP codes is typical. Combine this with single-use enforcement (invalidate the token immediately after successful verification) so a captured or leaked token has minimal value.

More in apis

Cubed

Write about the technologies shaping the future.

For developers, founders, and curious minds exploring AI, crypto, Web3, and emerging tech—signal over noise.

One free account across In Plain English, Stackademic, Venture, and Cubed.

How it works
  • AI, crypto & Web3
  • Software & emerging technologies
  • Analysis & practical resources
  • Thoughtful voices, not hype
1

Sign in

Google or GitHub

2

Complete profile

Takes a few minutes

3

Get approved & publish

Start sharing

Why write for Cubed?

The future deserves thoughtful voices, not just louder headlines.

Comments

Loading comments…

Posts Across the Network