Password Reset
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.
import { createTokenVerifier, createHash, createRateLimiter,} from 'ideal-auth';
const resetTokens = createTokenVerifier({ secret: process.env.SESSION_SECRET, expiryMs: 60 * 60 * 1000, // 1 hour});
const hash = createHash();
// Rate limit reset requests: 5 per 15 minutes per keyconst resetLimiter = createRateLimiter({ maxAttempts: 5, windowMs: 15 * 60 * 1000,});Complete flow
Section titled “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-passwordexport async function forgotPassword(req, res) {const { email } = req.body;const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;// Rate limit by IPconst 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 emailconst emailResult = await resetLimiter.attempt(`reset:email:${email.toLowerCase()}`,);if (!emailResult.allowed) {// Still return 200 to prevent enumerationreturn 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 existsif (!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 stringconst 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/:tokenexport async function resetPassword(req, res) {const { token } = req.params;const { password, confirmPassword } = req.body;if (password !== confirmPassword) {return res.status(400).json({ error: 'Passwords do not match' });}// Verify the tokenconst 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 passwordconst hashedPassword = await hash.make(password);// Update the password and record when it was changedawait db.user.update({where: { id: userId },data: {password: hashedPassword,passwordChangedAt: new Date(),},});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.SESSION_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 Dateif (user.passwordChangedAt) {const sessionIat = await getSessionIat(req); // see note belowconst changedAtSeconds = Math.floor(user.passwordChangedAt.getTime() / 1000,);if (sessionIat < changedAtSeconds) {// Session was created before the password was changed — force logoutawait session.logout();return null;}}return user;}
One-time use tokens
Section titled “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
Section titled “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
Section titled “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
Section titled “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)