Password reset is one of the most security-sensitive flows in any application. A poorly implemented reset flow can give an attacker full account access. This guide covers the complete flow using createTokenVerifier, createHash, and createRateLimiter from ideal-auth.
Setup
import {
createTokenVerifier,
createHash,
createRateLimiter,
} from 'ideal-auth';
const resetTokens = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET!,
purpose: 'password-reset', // tokens from other flows never verify here
expiryMs: 60 * 60 * 1000, // 1 hour
});
const hash = createHash();
// Rate limit reset requests: 5 per 15 minutes per key
const resetLimiter = createRateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
});Complete flow
-
User requests a password reset
The user submits their email address. Your server generates a signed token and sends it via email.
// POST /api/auth/forgot-password export async function forgotPassword(req, res) { const { email } = req.body; const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress; // Rate limit by IP const ipResult = await resetLimiter.attempt(`reset:ip:${ip}`); if (!ipResult.allowed) { return res.status(429).json({ error: 'Too many requests. Try again later.', retryAfter: ipResult.resetAt, }); } // Rate limit by email const emailResult = await resetLimiter.attempt( `reset:email:${email.toLowerCase()}`, ); if (!emailResult.allowed) { // Still return 200 to prevent enumeration return res.json({ message: 'If that email exists, a reset link was sent.' }); } const user = await db.user.findUnique({ where: { email: email.toLowerCase() }, }); // Always return success — do not reveal whether the email exists if (!user) { return res.json({ message: 'If that email exists, a reset link was sent.' }); } const token = resetTokens.createToken(String(user.id)); // Put the token in the URL path, not the query string const resetUrl = `${process.env.APP_URL}/reset-password/${token}`; await sendEmail({ to: user.email, subject: 'Reset your password', text: `Click here to reset your password: ${resetUrl}\n\nThis link expires in 1 hour.`, }); return res.json({ message: 'If that email exists, a reset link was sent.' }); } -
User clicks the reset link
The user arrives at your reset page and enters a new password. The token is extracted from the URL path.
// app/reset-password/[token]/page.tsx (Next.js example) export default function ResetPasswordPage({ params, }: { params: { token: string }; }) { return ( <form action={`/api/auth/reset-password/${params.token}`} method="POST"> <label> New Password <input type="password" name="password" required minLength={8} /> </label> <label> Confirm Password <input type="password" name="confirmPassword" required /> </label> <button type="submit">Reset Password</button> </form> ); } -
Verify the token and update the password
// POST /api/auth/reset-password/:token export async function resetPassword(req, res) { const { token } = req.params; const { password, confirmPassword } = req.body; // Rate limit to prevent flooding with invalid tokens const ip = req.headers['x-forwarded-for'] ?? req.ip ?? '127.0.0.1'; const { allowed } = await resetLimiter.attempt(`reset:${ip}`); if (!allowed) { return res.status(429).json({ error: 'Too many requests. Try again later.' }); } if (password !== confirmPassword) { return res.status(400).json({ error: 'Passwords do not match' }); } // Verify the token const result = resetTokens.verifyToken(token); if (!result) { return res.status(400).json({ error: 'Invalid or expired reset link' }); } const { userId, iatMs } = result; const user = await db.user.findUnique({ where: { id: userId } }); if (!user) { return res.status(400).json({ error: 'Invalid or expired reset link' }); } // Check if the password was changed after this token was issued. // This makes reset tokens one-time use. if (user.passwordChangedAt && user.passwordChangedAt.getTime() >= iatMs) { return res.status(400).json({ error: 'This reset link has already been used' }); } // Hash the new password const hashedPassword = await hash.make(password); // Update the password, record when it was changed, // and verify the email (they proved ownership by clicking the reset link) const updatedUser = await db.user.update({ where: { id: userId }, data: { password: hashedPassword, passwordChangedAt: new Date(), ...(user.emailVerifiedAt ? {} : { emailVerifiedAt: new Date() }), }, }); // Auto-login — the user just proved their identity const session = auth(); await session.login(updatedUser); return res.json({ message: 'Password updated successfully' }); } -
Invalidate existing sessions
After a password change, invalidate all existing sessions so the attacker (if any) is logged out.
// After updating the password, invalidate sessions. // ideal-auth sessions contain an `iat` (issued at) timestamp in seconds. // Store the password change time and check it in resolveUser. // In your auth config: const auth = createAuth({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: cookieBridge, resolveUser: async (id) => { const user = await db.user.findUnique({ where: { id } }); if (!user) return null; // The session's iat is in seconds. Compare against passwordChangedAt. // This check happens in the route handler (see below). return user; }, // ... });To invalidate sessions after a password change, check the session’s
iatagainst the user’spasswordChangedAtin your protected routes:async function getAuthenticatedUser(req) { const session = auth(); const user = await session.user(); if (!user) return null; // Session iat is in seconds; passwordChangedAt is a Date if (user.passwordChangedAt) { const sessionIat = await getSessionIat(req); // see note below const changedAtSeconds = Math.floor( user.passwordChangedAt.getTime() / 1000, ); if (sessionIat < changedAtSeconds) { // Session was created before the password was changed — force logout await session.logout(); return null; } } return user; }
One-time use tokens
The iatMs check above ensures each reset token can only be used once:
- Token is created at time
T(withiatMs = T) - When the password is reset,
passwordChangedAtis set tonew Date()(which is >=T) - If the same token is used again,
iatMs(which is stillT) will be less thanpasswordChangedAt, so the check fails
This approach is simpler than maintaining a token blocklist and has no cleanup overhead.
Rate limiting strategy
Apply rate limits at two levels:
| Key | Limit | Purpose |
|---|---|---|
reset:ip:{ip} |
5 per 15 min | Prevent an attacker from requesting resets for many emails |
reset:email:{email} |
3 per 15 min | Prevent flooding a single user’s inbox |
const resetLimiter = createRateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
});
// In the handler:
const ipResult = await resetLimiter.attempt(`reset:ip:${ip}`);
const emailResult = await resetLimiter.attempt(`reset:email:${email}`);Database schema
Add a password_changed_at column to your users table:
ALTER TABLE users ADD COLUMN password_changed_at TIMESTAMPTZ;This column serves double duty:
- One-time use tokens: tokens issued before this timestamp are rejected
- Session invalidation: sessions created before this timestamp are rejected
Security checklist
- Return the same response for valid and invalid emails (prevent user enumeration)
- Place tokens in the URL path, not the query string (query strings get logged)
- Set token expiry to 1 hour or less
- Use
iatMs+passwordChangedAtfor one-time use (no token blocklist needed) - Rate limit by both IP and email address
- Hash the new password with
createHashbefore storing - Update
passwordChangedAton every password change - Invalidate all existing sessions after a password change
- Require a minimum password length
- Send the reset email asynchronously if possible (to avoid timing-based enumeration)