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.
/forgot-password/reset-password?token=...The token carries its own expiry -- no separate table needed to track it.
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.
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.
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.
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.
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),
});
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));
}
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.
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.