Skip to content

Password Reset

Implement secure password reset flows with ideal-auth.

Updated View as Markdown

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

  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;
    
      // 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' });
    }
  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.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 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;
    }

One-time use tokens

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.

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 + 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)
Navigation

Type to search…

↑↓ navigate↵ selectEsc close