React Email13 min read

NextAuth Password Reset Emails with React Email: A Secure Token Flow

A practical, production-safe password reset flow for Auth.js (NextAuth) + Next.js App Router: one-time tokens, hashed storage, short expiry, rate limits, and a React Email template that looks legit.

R

React Emails Pro

March 5, 2026

Password reset is the email users will judge you by. If it’s slow, broken, or sketchy, they don’t think “auth bug.” They think “this product is unsafe.”

If you’re using Auth.js (NextAuth) in Next.js and sending emails with React Email, the goal is simple: a boring, secure flow. No cleverness. No magic links that never expire. No tokens sitting in logs.

This post covers a practical reset flow that works with App Router: generate a one-time token, store a hash, email the link, verify the hash, rotate the password, and invalidate the token.

Why Auth.js doesn’t “just do password reset”

Auth.js gives you sessions, providers, and callbacks. It doesn’t ship a turnkey password reset feature because:

  • Password reset is app-specific (DB schema, UX, policies)
  • Security choices vary (expiry, throttling, token storage)
  • You usually want a branded, product-specific email

So you build it yourself. The trick is avoiding the usual “works in dev, breaks in prod” traps.


The threat model (what we’re defending against)

A password reset system is basically a token mint. Your baseline should defend against:

  • Token leakage: URLs show up in logs, analytics, support tickets, screenshots.
  • Account enumeration: attackers learn whether an email exists.
  • Replay: old links used twice.
  • Brute force: repeated requests against one account.
Don’t store raw reset tokens in your database. Store a hash, like you do with passwords.

A minimal data model (token hash + expiry)

You can put this on your User record, but a separate table is cleaner because you can invalidate old tokens and audit usage.

db/schema.ts
// Example shape (Prisma/Drizzle/etc. — pick your poison)
// The important part: store *hash*, not token.

ResetToken {
  id: string
  userId: string
  tokenHash: string
  expiresAt: Date
  usedAt: Date | null
  createdAt: Date
  // Optional: requesterIp, requesterUserAgent
}

Expiry

Make it short. 30–60 minutes is a good default. Longer windows are convenient for users but massively increase the blast radius of leaked links.

If you want the UX of “longer,” keep the token short-lived but allow users to request a new one without friction.

Generate a token (and only store the hash)

Generate a cryptographically strong token, hash it, store the hash, then email the raw token.

app/lib/reset-token.ts
import crypto from "crypto";

export function createResetToken() {
  // 32 bytes => 64 hex chars. Plenty.
  const token = crypto.randomBytes(32).toString("hex");

  // Store a hash so DB leaks don't become account takeovers.
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex");

  return { token, tokenHash };
}

When the user clicks the link, hash the provided token and match it against tokenHash.


Send the reset email with React Email

Keep the email brutally clear: what happened, what to do, when it expires.

emails/password-reset.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Heading,
  Text,
  Button,
  Hr,
} from "@react-email/components";

export function PasswordResetEmail(props: {
  resetUrl: string;
  expiresInMinutes: number;
}) {
  const { resetUrl, expiresInMinutes } = props;

  return (
    <Html>
      <Head />
      <Body style={{ backgroundColor: "#0b0f17", padding: "24px" }}>
        <Container style={{ backgroundColor: "#0f172a", borderRadius: 16, padding: 24 }}>
          <Heading style={{ color: "#e2e8f0", margin: 0 }}>
            Reset your password
          </Heading>
          <Text style={{ color: "#cbd5e1" }}>
            Someone requested a password reset for your account.
          </Text>
          <Button
            href={resetUrl}
            style={{
              display: "inline-block",
              backgroundColor: "#22c55e",
              color: "#052e16",
              padding: "12px 16px",
              borderRadius: 12,
              fontWeight: 700,
              textDecoration: "none",
            }}
          >
            Reset password
          </Button>
          <Text style={{ color: "#94a3b8" }}>
            This link expires in {expiresInMinutes} minutes.
          </Text>
          <Hr style={{ borderColor: "#1f2a44", margin: "20px 0" }} />
          <Text style={{ color: "#94a3b8", fontSize: 13, margin: 0 }}>
            If you didn’t request this, you can ignore this email.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}
Don’t include anything user-specific in the reset link besides the token (no email, no userId). Treat the link as a credential.

Request flow: accept an email, send reset link, don’t leak existence

Your “forgot password” endpoint should always respond the same way, whether the email exists or not.

app/(auth)/forgot-password/action.ts
"use server";

import { z } from "zod";
import { render } from "@react-email/render";
import { createResetToken } from "@/app/lib/reset-token";
import { PasswordResetEmail } from "@/emails/password-reset";

const schema = z.object({ email: z.string().email() });

export async function requestPasswordReset(_: any, formData: FormData) {
  const result = schema.safeParse({ email: formData.get("email") });
  if (!result.success) return { ok: false };

  const email = result.data.email.toLowerCase();

  // 1) Lookup user
  const user = await db.user.findUnique({ where: { email } });

  // 2) Always return success (avoid enumeration)
  if (!user) return { ok: true };

  // 3) Create token + store hash
  const { token, tokenHash } = createResetToken();
  const expiresInMinutes = 45;

  await db.resetToken.create({
    data: {
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + expiresInMinutes * 60_000),
    },
  });

  // 4) Email the raw token
  const resetUrl = process.env.APP_URL + "/reset-password?token=" + token;
  const html = await render(
    <PasswordResetEmail resetUrl={resetUrl} expiresInMinutes={expiresInMinutes} />
  );

  await emailProvider.send({
    to: user.email,
    subject: "Reset your password",
    html,
  });

  return { ok: true };
}
Add rate limiting on this action (by IP + by email). It doesn’t need to be fancy; it needs to be real.

Verify flow: hash the token, validate expiry, enforce one-time use

When the user lands on your reset page, you can validate the token server-side before showing the “new password” form.

app/(auth)/reset-password/verify.ts
import crypto from "crypto";

export async function verifyResetToken(token: string) {
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex");

  const row = await db.resetToken.findFirst({
    where: {
      tokenHash,
      usedAt: null,
      expiresAt: { gt: new Date() },
    },
    include: { user: true },
  });

  return row; // null => invalid/expired/used
}
If you support multiple active tokens per user, keep it. If you want a stricter policy, delete old tokens when issuing a new one.

Consume flow: rotate password + invalidate token (transaction)

This step must be atomic. If password update succeeds but token invalidation fails, you’ve created a replay vulnerability.

app/(auth)/reset-password/action.ts
"use server";

import { z } from "zod";

const schema = z.object({
  token: z.string().min(10),
  password: z.string().min(12),
});

export async function resetPassword(_: any, formData: FormData) {
  const result = schema.safeParse({
    token: formData.get("token"),
    password: formData.get("password"),
  });

  if (!result.success) return { ok: false };

  const { token, password } = result.data;
  const row = await verifyResetToken(token);
  if (!row) return { ok: false };

  await db.$transaction(async (tx) => {
    await tx.user.update({
      where: { id: row.userId },
      data: { passwordHash: await hashPassword(password) },
    });

    await tx.resetToken.update({
      where: { id: row.id },
      data: { usedAt: new Date() },
    });

    // Optional: revoke sessions to force re-login.
    // await tx.session.deleteMany({ where: { userId: row.userId } });
  });

  return { ok: true };
}
“Minimum password length” isn’t a policy. Add a real one (deny breached passwords, add entropy rules, or at least block common patterns).

Copy that improves deliverability and trust

Security emails have a weird job: they need to be plain enough to feel real, but structured enough to not look like phishing.

  • Subject line: Reset your password
  • Preheader: This link expires in 45 minutes
  • CTA label: Reset password
  • Footer line: If you didn’t request this, ignore this email
If you’re seeing spam placement, check your URL patterns and sender alignment first. This is usually infrastructure, not “copywriting.”

Where React Email fits (and why it’s worth it)

You could send a barebones text email. But React Email gives you:

  • Componentized layout (shared branding, shared footer/legal)
  • Type-safe props (you stop “forgetting” required fields)
  • Easier preview/testing (snapshots, fixtures, deterministic output)

If you want a concrete example of a production-grade sending setup, read React Email + Resend: Production Checklist and borrow the same discipline: validated inputs, deterministic templates, and boring observability.

The best password reset flow is the one that never surprises you. Short-lived tokens. Hashed storage. One-time use. Rate limits. And an email that looks like it belongs to your product — not to a scammer.

Quick checklist (ship this, sleep better)

  1. Always respond “check your inbox” (no account enumeration)
  2. Generate token with crypto randomness; store only a hash
  3. Expires in 30–60 minutes
  4. One-time use (mark used inside the same DB transaction)
  5. Rate limit by IP and by email
  6. Log events, never log the raw token

If you want to compare how your current email reads to users, open a sent reset email and ask: does this look like a bank would send it? If not, simplify.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates