Skip to content

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 key
const resetLimiter = createRateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
});
  1. 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.' });
    }
  2. 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>
    );
    }
  3. 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;
    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 and record when it was changed
    await db.user.update({
    where: { id: userId },
    data: {
    password: hashedPassword,
    passwordChangedAt: new Date(),
    },
    });
    return res.json({ message: 'Password updated successfully' });
    }
  4. 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 iat against the user’s passwordChangedAt in 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;
    }

The iatMs check above ensures each reset token can only be used once:

  1. Token is created at time T (with iatMs = T)
  2. When the password is reset, passwordChangedAt is set to new Date() (which is >= T)
  3. If the same token is used again, iatMs (which is still T) will be less than passwordChangedAt, so the check fails

This approach is simpler than maintaining a token blocklist and has no cleanup overhead.

Apply rate limits at two levels:

KeyLimitPurpose
reset:ip:{ip}5 per 15 minPrevent an attacker from requesting resets for many emails
reset:email:{email}3 per 15 minPrevent 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}`);

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
  • 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 + passwordChangedAt for one-time use (no token blocklist needed)
  • Rate limit by both IP and email address
  • Hash the new password with createHash before storing
  • Update passwordChangedAt on 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)