Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Password reset in Next.js App Router -- JWT tokens, Resend email, and Drizzle ORM

June 8, 2026
nextjsauthjwtresenddrizzle-orm

Password reset in Next.js App Router -- JWT tokens, Resend email, and Drizzle ORM

Most SaaS apps need a password reset flow. This post shows the complete pattern -- request, email, and reset -- using the JWT and Drizzle ORM foundation already in this boilerplate. No extra tables. No third-party auth library.

The shape of the flow

  1. User submits their email on /forgot-password
  2. API generates a short-lived JWT reset token and sends a link via Resend
  3. User clicks the link, lands on /reset-password?token=...
  4. API validates the token and updates the password hash

The token carries its own expiry -- no separate table needed to track it.

Step 1 -- Reset token helpers

Add two functions to lib/auth.ts alongside your existing JWT helpers:

export function generateResetToken(userId: string): string {
  return jwt.sign({ sub: userId, purpose: 'reset' }, process.env.JWT_SECRET!, {
    expiresIn: '1h',
  });
}
 
export function verifyResetToken(token: string): { sub: string } {
  const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
    sub: string;
    purpose: string;
  };
  if (payload.purpose !== 'reset') throw new HttpError(400, 'Invalid token');
  return payload;
}

The purpose claim prevents a login token from being reused as a reset token.

Step 2 -- Forgot password route

Create app/api/auth/forgot-password/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { handleError } from '@/lib/errors';
import { forgotPasswordSchema } from '@/modules/user/user.validation';
import { userService } from '@/modules/user';
 
export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const result = forgotPasswordSchema.safeParse(body);
    if (!result.success) {
      return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
    }
    await userService.sendPasswordReset(result.data.email);
    return NextResponse.json({ ok: true });
  } catch (error: unknown) {
    return handleError(error);
  }
}

Always return 200 even when the email does not exist. Leaking that would let attackers enumerate users.

Step 3 -- Service logic

In modules/user/user.service.ts, add sendPasswordReset:

async sendPasswordReset(email: string): Promise<void> {
  const user = await userRepo.findByEmail(email);
  if (!user) return; // silent -- do not leak
 
  const token = generateResetToken(user.id);
  const resetUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/reset-password?token=${token}`;
 
  await emailService.sendEmail({
    to: user.email,
    subject: 'Reset your password',
    react: React.createElement(ResetPasswordEmail, { appName: 'MyApp', resetUrl }),
  });
}

Token generation happens in the service, not the route. The route stays thin.

Step 4 -- Reset password route

Create app/api/auth/reset-password/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { handleError } from '@/lib/errors';
import { resetPasswordSchema } from '@/modules/user/user.validation';
import { userService } from '@/modules/user';
 
export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const result = resetPasswordSchema.safeParse(body);
    if (!result.success) {
      return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
    }
    await userService.resetPassword(result.data.token, result.data.password);
    return NextResponse.json({ ok: true });
  } catch (error: unknown) {
    return handleError(error);
  }
}

And in the service:

async resetPassword(token: string, newPassword: string): Promise<void> {
  const { sub: userId } = verifyResetToken(token);
  const hash = await bcrypt.hash(newPassword, 12);
  await userRepo.updatePassword(userId, hash);
}

verifyResetToken throws HttpError(400, 'Invalid token') if the token is expired, tampered, or has the wrong purpose. handleError in the route catches it and returns the right HTTP status.

Step 5 -- Validation schemas

In modules/user/user.validation.ts:

export const forgotPasswordSchema = z.object({
  email: z.string().email(),
});
 
export const resetPasswordSchema = z.object({
  token: z.string().min(1),
  password: z.string().min(8),
});

Step 6 -- Repo update

Add updatePassword to modules/user/user.repo.ts:

async updatePassword(userId: string, hash: string): Promise<void> {
  await db
    .update(userTable)
    .set({ passwordHash: hash, updatedAt: new Date() })
    .where(eq(userTable.id, userId));
}

What you get

Six files, no extra table:

lib/auth.ts                            -- generateResetToken, verifyResetToken
app/api/auth/forgot-password/route.ts
app/api/auth/reset-password/route.ts
modules/user/user.service.ts           -- sendPasswordReset, resetPassword
modules/user/user.validation.ts        -- forgotPasswordSchema, resetPasswordSchema
modules/user/user.repo.ts              -- updatePassword

The JWT expiry is the TTL. A used token technically remains valid until it expires -- if you need single-use guarantees, add a passwordChangedAt column to userTable, encode the issued-at time in the token, and reject tokens issued before the last password change.

Next step

Wire up the /forgot-password and /reset-password pages with React Hook Form and the Zod schemas defined above. The form-to-API wiring follows the same pattern from Form validation in Next.js App Router with React Hook Form and Zod.