---
title: "Password Reset"
description: "Implement secure password reset flows with ideal-auth."
---

> Documentation Index
> Fetch the complete documentation index at: https://ramonmalcolm10.github.io/ideal-auth/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

## Setup

```ts
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.

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

   > **Caution**
>
> Always return the same response whether the email exists or not. Returning different messages for "email found" vs. "email not found" allows attackers to enumerate which emails are registered.

   > **Note**
>
> Place the token in the URL path (`/reset-password/TOKEN`) rather than the query string (`/reset-password?token=TOKEN`). Query strings are logged by web servers, proxies, and browser history. URL paths are less likely to leak.

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.

```tsx
   // 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**

```ts
   // 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.

```ts
   // 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:

```ts
   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;
   }
```

   > **Note**
>
> The session payload `iat` is in seconds (Unix timestamp). The token verifier's `iatMs` is in milliseconds. Be careful with the unit conversion when comparing these values.

## 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 |

```ts
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}`);
```

> **Tip**
>
> For the IP rate limit, return a 429 response so legitimate users know to wait. For the email rate limit, return 200 with the standard success message to avoid leaking information about whether the email is registered.

## Database schema

Add a `password_changed_at` column to your users table:

```sql
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)

Source: https://ramonmalcolm10.github.io/ideal-auth/guides/password-reset/index.mdx
