DEV Community

Cover image for How to build a secure password reset flow in Next.js (the short version)
GDS K S
GDS K S

Posted on

How to build a secure password reset flow in Next.js (the short version)

Last month I reviewed a friend's side project. He had 200 paying users, Stripe set up, a working dashboard. The password reset flow sent a reset token in a query string, stored it in plain text in Postgres, and never expired it. Anyone who read the database could take over any account, permanently.

He had written it in 2 hours. It looked fine. That is the problem with reset flows. They look fine until someone reads them carefully.

Here is a version that does not embarrass you, followed by the same thing in 12 lines using kavachOS.

What a good reset flow actually does

Six things, in order:

  1. Accepts an email and always responds with the same 200, whether the email exists or not
  2. Generates a 32 byte random token, hashes it before storing
  3. Stores the hash with a 15 minute expiry and a one time use flag
  4. Sends the raw token to the user's inbox
  5. On submit, hashes the incoming token and compares against storage
  6. Marks the token used, writes a new password, rotates every active session

If any one of those is missing you have a bug. Most tutorials cover 1 and 3. Almost nobody covers 6, which is why most apps stay logged in as the attacker after a reset.

The from scratch version

The schema

// db/schema.ts
import { bigserial, pgTable, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: bigserial("id", { mode: "number" }).primaryKey(),
  email: text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
});

export const passwordResetTokens = pgTable("password_reset_tokens", {
  id: bigserial("id", { mode: "number" }).primaryKey(),
  userId: bigserial("user_id", { mode: "number" }).notNull(),
  tokenHash: text("token_hash").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  usedAt: timestamp("used_at"),
});
Enter fullscreen mode Exit fullscreen mode

Note the tokenHash. If your database leaks, the leaked rows cannot be used to reset anyone's password. If you store the raw token, you have handed the attacker a working exploit.

The request endpoint

// app/api/auth/forgot-password/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/db";
import { users, passwordResetTokens } from "@/db/schema";
import { eq } from "drizzle-orm";
import { randomBytes, createHash } from "crypto";
import { sendResetEmail } from "@/lib/email";
import { rateLimit } from "@/lib/rate-limit";

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

export async function POST(req: NextRequest) {
  const ip = req.headers.get("x-forwarded-for") ?? "unknown";
  if (await rateLimit(`forgot:${ip}`, { max: 5, window: 60 })) {
    return NextResponse.json({ ok: true });
  }

  const parsed = body.safeParse(await req.json());
  if (!parsed.success) return NextResponse.json({ ok: true });

  const user = await db.query.users.findFirst({
    where: eq(users.email, parsed.data.email.toLowerCase()),
  });

  if (user) {
    const raw = randomBytes(32).toString("base64url");
    const tokenHash = createHash("sha256").update(raw).digest("hex");
    await db.insert(passwordResetTokens).values({
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 15 * 60 * 1000),
    });
    await sendResetEmail(user.email, raw);
  }

  return NextResponse.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Four things that matter here:

The endpoint always returns 200. An attacker probing with 10,000 emails learns nothing. If you return 404 for unknown emails, you have just built an account enumeration endpoint.

The rate limit is on IP, 5 per minute. It will not stop a distributed attacker but it raises the cost enough that nobody bothers.

On parse failure, still 200. A 400 leaks "this email was malformed" vs "this email was fine but not in the database".

The raw token exists for exactly one HTTP response and one email send. It never hits the database.

The reset endpoint

// app/api/auth/reset-password/route.ts
export async function POST(req: NextRequest) {
  const ip = req.headers.get("x-forwarded-for") ?? "unknown";
  if (await rateLimit(`reset:${ip}`, { max: 10, window: 60 })) {
    return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
  }

  const parsed = z
    .object({ token: z.string().min(32), password: z.string().min(12) })
    .safeParse(await req.json());
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
  }

  const { token, password } = parsed.data;
  const tokenHash = createHash("sha256").update(token).digest("hex");

  const row = await db.query.passwordResetTokens.findFirst({
    where: and(
      eq(passwordResetTokens.tokenHash, tokenHash),
      gt(passwordResetTokens.expiresAt, new Date()),
      isNull(passwordResetTokens.usedAt),
    ),
  });

  if (!row) {
    return NextResponse.json(
      { error: "Invalid or expired link" },
      { status: 400 },
    );
  }

  const passwordHash = await bcrypt.hash(password, 12);

  await db.transaction(async (tx) => {
    await tx.update(users).set({ passwordHash }).where(eq(users.id, row.userId));
    await tx
      .update(passwordResetTokens)
      .set({ usedAt: new Date() })
      .where(eq(passwordResetTokens.id, row.id));
  });

  await rotateSession(row.userId);
  return NextResponse.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Three things that people miss:

The transaction. If the password update succeeds but the token update fails, the token can be used again. Wrap them or do not bother.

rotateSession. Password reset must invalidate every active session for that user. If an attacker has a stolen session cookie, changing the password does not kick them out. Rotate.

Same error for expired, used, and never existed. All three get a 400 with "invalid or expired link". No timing differences, no leaked state.

Rate limiting

// lib/rate-limit.ts
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();

export async function rateLimit(
  key: string,
  opts: { max: number; window: number },
): Promise<boolean> {
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, opts.window);
  return count > opts.max;
}
Enter fullscreen mode Exit fullscreen mode

On Cloudflare Workers, swap Redis for KV or Durable Objects. Same shape.

The kavachOS version

I wrote the code above maybe a dozen times in different projects before I gave up and wrote a library. That library is kavachOS. Here is the same flow with it:

Install

pnpm add kavachos @kavachos/nextjs
Enter fullscreen mode Exit fullscreen mode

Configure

// auth.ts
import { kavachos } from "kavachos";
import { nextjsAdapter } from "@kavachos/nextjs";

export const auth = kavachos({
  adapter: nextjsAdapter(),
  database: process.env.DATABASE_URL!,
  email: {
    provider: "resend",
    apiKey: process.env.RESEND_API_KEY!,
    from: "noreply@example.com",
  },
  passwordReset: {
    tokenTTL: "15m",
    oneTimeUse: true,
    rotateSessionsOnReset: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

Mount the routes

// app/api/auth/[...kavachos]/route.ts
import { auth } from "@/auth";
export const { GET, POST } = auth.handlers;
Enter fullscreen mode Exit fullscreen mode

The form

// app/auth/forgot/page.tsx
"use client";
import { useAuth } from "@kavachos/nextjs";

export default function Forgot() {
  const { requestReset, pending, status } = useAuth();
  return (
    <form action={requestReset}>
      <input name="email" type="email" required />
      <button disabled={pending}>Send reset link</button>
      {status === "ok" && <p>If that email exists, a link is on the way.</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

That is the whole thing. The tokens are hashed, the expiry is 15 minutes, the rate limits are on, the sessions rotate on success, and the error messages are constant time. If you look at the source, it is almost exactly the code above, just packaged.

Docs for the password reset flow are at kavachos.com/docs/password-reset. The Next.js adapter is at kavachos.com/docs/adapters/nextjs.

Three mistakes I keep finding in code reviews

Storing tokens in plain text

One in three repos I have looked at does this. If the database leaks, every active reset link is live. Hash before storing. Always. The raw token should exist in exactly two places: the HTTP response that mails it out, and the user's inbox.

Using Math.random() for the token

Math.random() is not cryptographic. It is a deterministic PRNG seeded from the process start time. Use crypto.randomBytes(32) on the server or crypto.getRandomValues() on the edge.

24 hour expiries

If the user is going to click the link, they click it in 2 minutes. A 24 hour window exists to make the support team's job easier, but it gives an attacker who compromised an email backup all day to use the link. 15 minutes is fine. If the user misses the window they can click "forgot password" again.

Testing checklist before you ship

  • POST /forgot-password returns 200 for both known and unknown emails
  • Response time for known and unknown emails is within 50ms (timing defense)
  • Rate limit triggers at the 6th request from one IP per minute
  • Token row contains tokenHash, not the raw value
  • Expired tokens are rejected with the same 400 as invalid ones
  • Used tokens are rejected with the same 400 as invalid ones
  • After a successful reset, all sessions for that user are invalidated
  • A log line is written for reset_requested, reset_completed, reset_failed

That last point matters when a user emails you at midnight saying they never got the email. You will want those logs.

Questions I expect in the comments

Why not JWTs for the token? A JWT is stateless. You cannot revoke it on use. If you use a JWT for reset, the same link works until the expiry for anyone who saved it. You lose the one time property. Use a database row.

Why 15 minutes? Because the user clicks within 2. The only thing a longer window does is give attackers more runway.

Is email actually secure enough for this? No, email is terrible. It is also what everyone has. Which is why the link expires in 15 minutes, is one time use, and rotates sessions on success. You are containing the blast radius, not preventing the attack.

What I will write next

I am doing one of these a day for the next two weeks. Next up: magic link login in Next.js, same rigor, with and without a library. Follow me here if you want the next one in your feed.

Comment with the auth thing you have been putting off. If I get enough of the same one, I will write it up.


Gagan Deep Singh builds open source tools at Glincker. Currently working on kavachOS (open source auth for AI agents and humans) and AskVerdict (multi-model AI verdicts).

If this was useful, follow me on Dev.to or X where I post weekly.

Top comments (0)