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.
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.
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.
// 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.
Generate a token (and only store the hash)
Generate a cryptographically strong token, hash it, store the hash, then email the raw token.
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.
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>
);
}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.
"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 };
}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.
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
}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.
"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 };
}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
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.
Quick checklist (ship this, sleep better)
- Always respond “check your inbox” (no account enumeration)
- Generate token with crypto randomness; store only a hash
- Expires in 30–60 minutes
- One-time use (mark used inside the same DB transaction)
- Rate limit by IP and by email
- 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.