Skip to content

feat: add forgot password & reset password flow - #68

Merged
Bhav-ikkk merged 3 commits into
mainfrom
feat/forgot-password
Mar 30, 2026
Merged

feat: add forgot password & reset password flow#68
Bhav-ikkk merged 3 commits into
mainfrom
feat/forgot-password

Conversation

@Bhav-ikkk

@Bhav-ikkk Bhav-ikkk commented Mar 30, 2026

Copy link
Copy Markdown
Collaborator
  • Add password_reset_tokens table to schema
  • POST /api/auth/forgot-password — rate-limited, no email enumeration
  • POST /api/auth/reset-password — token hash validation, bcrypt, atomic tx
  • /forgot-password page — email input with success/error feedback
  • /reset-password page — new password + confirm with auto-redirect
  • Add 'Forgot Password?' link to login page (existing-user mode)

Security: SHA-256 token hashing, 1hr expiry, one-time use, rate limiting

Description

Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.

Fixes # (issue)

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.

  • Test A
  • Test B

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Summary by CodeRabbit

  • New Features

    • Password reset flow: request a reset link by email, receive a one-hour expiring token, and complete password reset via a dedicated page.
    • "Forgot Password?" action added to the login screen.
  • Security & Reliability

    • Rate limiting applied to password-reset endpoints to reduce abuse.
    • All previous reset links are invalidated when a new one is issued.
  • Bug Fixes

    • Improved email validation on registration to enforce length and format.

- Add password_reset_tokens table to schema
- POST /api/auth/forgot-password — rate-limited, no email enumeration
- POST /api/auth/reset-password — token hash validation, bcrypt, atomic tx
- /forgot-password page — email input with success/error feedback
- /reset-password page — new password + confirm with auto-redirect
- Add 'Forgot Password?' link to login page (existing-user mode)

Security: SHA-256 token hashing, 1hr expiry, one-time use, rate limiting
Copilot AI review requested due to automatic review settings March 30, 2026 03:07
@coderabbitai

coderabbitai Bot commented Mar 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Added a password reset feature: new database table for token storage, two backend API endpoints to request and consume reset tokens (with rate limiting, hashing, expiry, and transactional updates), and frontend pages/UI to request a reset and set a new password. Login page gains a "Forgot Password?" link.

Changes

Cohort / File(s) Summary
Database Schema
schema.sql
Added password_reset_tokens table (id, user_id, token_hash, expires_at, used, created_at) with FK → users(id) ON DELETE CASCADE and indexes on token_hash and user_id.
Auth API: Forgot Password
src/pages/api/auth/forgot-password.js
New POST endpoint: rate limiting (15m/5), strict email validation, prevents enumeration, marks prior unused tokens as used, generates 32-byte token, stores SHA‑256 hash with 1h expiry, sends reset link email, returns 200 on success; logs and returns 500 on unexpected errors.
Auth API: Reset Password
src/pages/api/auth/reset-password.js
New POST endpoint: rate limiting (15m/10), validates token and password, verifies token hash/expiry/used, bcrypt-hashes new password (cost 12), performs transactional update to set user password and mark tokens used, returns specific 400 errors for invalid/expired/used tokens and 200 on success.
Forgot Password Page
src/pages/forgot-password/index.js
New page component with controlled email input, client-side validation, submits to /api/auth/forgot-password, shows success/failure alerts, loading states, and navigation to login.
Reset Password Page
src/pages/reset-password/index.js
New page reading token from URL, controlled password + confirm fields, client validation, submits { token, password } to /api/auth/reset-password, handles success (redirect to login after 3s) and error states, disables inputs while loading.
Login Page Update
src/pages/login/index.js
UI change: password field now includes a right-aligned Forgot Password? button that navigates to /forgot-password; existing password handling preserved.
Register Email Validation
src/pages/api/auth/register.js
Tightened email validation: replaced regex with /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/ and added max-length check (<= 254).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant ForgotPage as Forgot Password Page
    participant API_Forgot as /api/auth/forgot-password
    participant DB as Database
    participant Email as Email Service
    participant ResetPage as Reset Password Page
    participant API_Reset as /api/auth/reset-password

    User->>ForgotPage: Submit email
    ForgotPage->>API_Forgot: POST { email }
    API_Forgot->>API_Forgot: Rate limit check
    API_Forgot->>DB: Query user by email
    alt user exists
        API_Forgot->>API_Forgot: Generate token, hash (SHA-256)
        API_Forgot->>DB: Mark prior unused tokens used
        API_Forgot->>DB: Insert token_hash with expires_at
        API_Forgot->>Email: Send reset link (includes raw token)
    end
    API_Forgot-->>ForgotPage: 200 (generic success)

    User->>ResetPage: Open link with token
    ResetPage->>ResetPage: Extract token from URL
    User->>ResetPage: Submit new password
    ResetPage->>API_Reset: POST { token, password }
    API_Reset->>API_Reset: Rate limit check
    API_Reset->>API_Reset: Hash token (SHA-256)
    API_RESET->>DB: Query token + user
    alt token valid & not expired & not used
        API_Reset->>API_Reset: Hash password (bcrypt)
        API_Reset->>DB: BEGIN TRANSACTION
        API_Reset->>DB: Update users.password_hash
        API_Reset->>DB: Mark token used and other tokens used
        API_Reset->>DB: COMMIT
        API_Reset-->>ResetPage: 200 success
    else invalid/expired/used
        API_Reset-->>ResetPage: 400 error (specific)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~35 minutes

Poem

🐰 A Little Hop for Safe Logins
I nibble bytes and hash the keys,
Tokens hide beneath the trees,
Emails flutter, links set free,
Bcrypt buries secrets, one-two-three,
Users hop back in with ease 🥕🔐

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add forgot password & reset password flow' accurately and concisely describes the primary changeset, which introduces a complete forgot/reset password feature across database, API, and UI layers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/forgot-password

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/pages/api/auth/forgot-password.js Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a complete “forgot password / reset password” flow across UI, API, and DB to support password recovery for credential-based accounts.

Changes:

  • Added /forgot-password and /reset-password pages with form validation + status feedback.
  • Added API routes for forgot/reset password with token hashing + expiry + rate limiting.
  • Added password_reset_tokens table to the schema and linked the flow from the login page.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/pages/forgot-password/index.js New UI page to request a reset link via email.
src/pages/reset-password/index.js New UI page to set a new password using a token from the reset link.
src/pages/login/index.js Adds “Forgot Password?” link in existing-user login mode.
src/pages/api/auth/forgot-password.js Generates/stores reset token hash and sends reset email (rate-limited).
src/pages/api/auth/reset-password.js Validates token + expiry and updates password, marking token(s) as used.
schema.sql Adds password_reset_tokens table + indexes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread schema.sql
Comment on lines 182 to +196

-- Password reset tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(128) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change updates the root schema.sql but doesn’t add a numbered migration in src/services/database/migrations/ (used by the migration runner) and also leaves src/services/database/schema.sql without this table, so schema sources will diverge. Add the next migration (e.g. 009_add_password_reset_tokens.sql) and keep both schema files in sync per the DB docs/README.

Suggested change
-- Password reset tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(128) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);

Copilot uses AI. Check for mistakes.
Comment thread schema.sql
Comment on lines +189 to +190
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used and created_at are nullable as defined here; that can lead to ambiguous semantics (NULL vs FALSE) and makes queries more error-prone. Consider used BOOLEAN NOT NULL DEFAULT FALSE and created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP (and optionally a UNIQUE constraint/index on token_hash since the API assumes one row per hash).

Suggested change
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,

Copilot uses AI. Check for mistakes.
Comment on lines +182 to +185
<InputAdornment position='end'>
<IconButton size='small' onClick={() => setShowPassword(!showPassword)} edge='end'>
{showPassword ? <Icon icon='tabler:eye-off' fontSize={17} /> : <Icon icon='tabler:eye' fontSize={17} />}
</IconButton>

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The password visibility IconButtons don’t have accessible labels. Add aria-label (and ideally aria-pressed) so screen readers can announce the control purpose/state.

Copilot uses AI. Check for mistakes.
Comment on lines +236 to +238
all: 'unset', display: 'inline', cursor: 'pointer',
color: 'primary.main', fontWeight: 700, fontSize: 'inherit', fontFamily: 'inherit',
'&:hover, &:focus-visible': { textDecoration: 'underline' }

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This button-styled Box removes all default button styles and only adds underline on focus; unlike similar buttons elsewhere (e.g., login/forgot pages), it lacks a visible focus outline, which hurts keyboard accessibility. Add a :focus-visible outline (or use MUI Link/Button) for consistent focus treatment.

Suggested change
all: 'unset', display: 'inline', cursor: 'pointer',
color: 'primary.main', fontWeight: 700, fontSize: 'inherit', fontFamily: 'inherit',
'&:hover, &:focus-visible': { textDecoration: 'underline' }
all: 'unset',
display: 'inline',
cursor: 'pointer',
color: 'primary.main',
fontWeight: 700,
fontSize: 'inherit',
fontFamily: 'inherit',
'&:hover': { textDecoration: 'underline' },
'&:focus-visible': {
textDecoration: 'underline',
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: 2,
borderRadius: 1
}

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +90
} catch (err) {
console.error('[ForgotPassword] Error:', err)

return res.status(500).json({ error: 'Something went wrong. Please try again later.' })
}

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch path returns a 500, which breaks the “always returns 200 to avoid email enumeration” guarantee. If SMTP/sendMail fails only when a user exists (because unknown emails return early), an attacker can infer account existence via 200 vs 500; consider always returning the generic 200 response after validation and logging/queuing the email send failure instead.

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +110
<h2 style="margin:0 0 12px;font-size:18px;color:#1a1a2e;">Password Reset</h2>
<p style="margin:0 0 20px;font-size:14px;line-height:1.6;color:#555;">
Hi <strong>${name}</strong>, we received a request to reset your password.
Click the button below to choose a new one. This link expires in <strong>1 hour</strong>.
</p>

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTML email template interpolates name directly into HTML. Since users.name can contain arbitrary characters, this can break markup or enable HTML injection in email clients; escape the name (and any other user-controlled fields) before embedding in HTML (see existing HTML escaping patterns in the repo).

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +76
const resetRecord = await dbOneOrNone(
`SELECT prt.id, prt.user_id, prt.expires_at, prt.used, u.email
FROM password_reset_tokens prt
JOIN users u ON u.id = prt.user_id
WHERE prt.token_hash = $1`,
[tokenHash]
)

if (!resetRecord) {
return res.status(400).json({ error: 'Invalid or expired reset link. Please request a new one.' })
}

if (resetRecord.used) {
return res.status(400).json({ error: 'This reset link has already been used. Please request a new one.' })
}

if (new Date(resetRecord.expires_at) < new Date()) {
return res.status(400).json({ error: 'This reset link has expired. Please request a new one.' })
}

// Hash the new password
const passwordHash = await bcrypt.hash(password, 12)

// Update password & mark token used in a transaction
await dbTx(async t => {
await t.none(
'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
[passwordHash, resetRecord.user_id]
)
await t.none(
'UPDATE password_reset_tokens SET used = TRUE WHERE id = $1',
[resetRecord.id]
)
// Invalidate all other unused tokens for this user
await t.none(
'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND id != $2 AND used = FALSE',
[resetRecord.user_id, resetRecord.id]
)

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reset token validation isn’t atomic: the code reads used/expires_at outside the transaction and then unconditionally updates the token row, so two concurrent requests can both pass validation and both reset the password before either marks the token used. Make the update conditional (WHERE used = FALSE AND expires_at > NOW()) and verify rowCount/RETURNING, or SELECT … FOR UPDATE inside the tx.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +74
// Redirect to login after 3 seconds
setTimeout(() => router.push('/login'), 3000)
} else {

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTimeout is scheduled on success without cleanup, so navigating away before it fires can trigger a state update/navigation after unmount. Store the timeout id and clear it in a useEffect cleanup (or redirect immediately once serverMsg is set).

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +71
// Invalidate any existing unused tokens for this user
await dbNone(
'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND used = FALSE',
[user.id]
)

// Generate a cryptographically secure token
const rawToken = crypto.randomBytes(32).toString('hex')

// Store only the SHA-256 hash in DB
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex')

// Token expires in 1 hour
const expiresAt = new Date(Date.now() + 60 * 60 * 1000)

await dbNone(
'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
)

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token invalidation + insert isn’t atomic: concurrent forgot-password requests for the same user can both mark old tokens used and then each insert a new unused token, leaving multiple valid reset links. Wrap the invalidate+insert in a single dbTx with row locking, or enforce a partial unique constraint (e.g., one unused token per user) and handle conflicts deterministically.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/pages/reset-password/index.js (1)

72-73: Consider clearing the redirect timeout on unmount.

If the user navigates away before the 3-second timeout completes, the router.push('/login') will still fire, potentially causing unexpected navigation. This is a minor edge case.

Proposed fix using useEffect for cleanup

Add a ref to track the timeout and clear it on unmount:

// At the top of the component
const redirectTimerRef = useRef(null)

// In handleSubmit success block
redirectTimerRef.current = setTimeout(() => router.push('/login'), 3000)

// Add cleanup effect
useEffect(() => {
  return () => {
    if (redirectTimerRef.current) clearTimeout(redirectTimerRef.current)
  }
}, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/reset-password/index.js` around lines 72 - 73, The timeout started
with setTimeout(() => router.push('/login'), 3000) can fire after the component
unmounts causing unexpected navigation; fix by using a ref (e.g.,
redirectTimerRef via useRef) to store the timeout id when setting it inside
handleSubmit's success branch and add a cleanup effect (useEffect with empty
deps) that clears the timeout (clearTimeout(redirectTimerRef.current)) on
unmount so router.push('/login') won't run after the component has been torn
down.
schema.sql (1)

187-187: Consider adding a UNIQUE constraint on token_hash.

While SHA-256 collisions are cryptographically improbable, adding a UNIQUE constraint on token_hash provides defense-in-depth and ensures data integrity at the database level. It also makes the lookup query more efficient.

Proposed change
-    token_hash VARCHAR(128) NOT NULL,
+    token_hash VARCHAR(128) NOT NULL UNIQUE,

If using UNIQUE on the column, the separate index on token_hash (line 194) becomes redundant and can be removed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@schema.sql` at line 187, Add a UNIQUE constraint to the token_hash column
(e.g., change token_hash VARCHAR(128) NOT NULL to include UNIQUE or add a
table-level UNIQUE (token_hash)) to enforce uniqueness at the DB level and
improve lookup performance; if you add UNIQUE, remove the separate token_hash
index defined later (the redundant index on token_hash) to avoid duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/pages/api/auth/forgot-password.js`:
- Line 108: Escape the user-provided name before inserting it into the HTML
email template to prevent HTML injection: add or reuse a small HTML-escaping
helper (e.g., escapeHtml) and call it when building the template instead of
interpolating raw name; update the email creation/site-mailing code in
src/pages/api/auth/forgot-password.js (the template that contains Hi
<strong>${name}</strong>) to use escapeHtml(name) and ensure the helper properly
replaces &, <, >, ", and ' characters.
- Around line 35-38: The emailRegex validation in forgot-password.js (emailRegex
and trimmedEmail) can be exploited for ReDoS; update the handler to first
enforce a strict length constraint on trimmedEmail (e.g., max 254 chars and a
reasonable min) before running any regex, or replace the custom regex with a
well-tested linear-time validator (e.g., validator.isEmail) and use that
instead; ensure you update the validation branch that currently returns
res.status(400).json({ error: 'Invalid email format' }) to handle the new length
check and/or validator call and import the validator module if used.

---

Nitpick comments:
In `@schema.sql`:
- Line 187: Add a UNIQUE constraint to the token_hash column (e.g., change
token_hash VARCHAR(128) NOT NULL to include UNIQUE or add a table-level UNIQUE
(token_hash)) to enforce uniqueness at the DB level and improve lookup
performance; if you add UNIQUE, remove the separate token_hash index defined
later (the redundant index on token_hash) to avoid duplication.

In `@src/pages/reset-password/index.js`:
- Around line 72-73: The timeout started with setTimeout(() =>
router.push('/login'), 3000) can fire after the component unmounts causing
unexpected navigation; fix by using a ref (e.g., redirectTimerRef via useRef) to
store the timeout id when setting it inside handleSubmit's success branch and
add a cleanup effect (useEffect with empty deps) that clears the timeout
(clearTimeout(redirectTimerRef.current)) on unmount so router.push('/login')
won't run after the component has been torn down.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a49f1f5-570d-475a-b230-285fe64c3adf

📥 Commits

Reviewing files that changed from the base of the PR and between 21d554e and 559ed7f.

📒 Files selected for processing (6)
  • schema.sql
  • src/pages/api/auth/forgot-password.js
  • src/pages/api/auth/reset-password.js
  • src/pages/forgot-password/index.js
  • src/pages/login/index.js
  • src/pages/reset-password/index.js

Comment thread src/pages/api/auth/forgot-password.js Outdated
<tr><td style="padding:32px;">
<h2 style="margin:0 0 12px;font-size:18px;color:#1a1a2e;">Password Reset</h2>
<p style="margin:0 0 20px;font-size:14px;line-height:1.6;color:#555;">
Hi <strong>${name}</strong>, we received a request to reset your password.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Escape user-provided name in HTML email template.

The name value is interpolated directly into HTML without escaping. If a user registered with a name containing HTML characters (e.g., <script> or &), it could cause rendering issues or, in edge cases, be exploited in email clients that don't fully sanitize HTML.

Proposed fix: Add HTML escaping helper
+function escapeHtml(str) {
+  return str
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
 function buildResetEmailHtml(name, resetUrl) {
+  const safeName = escapeHtml(name)
   return `
   ...
-              Hi <strong>${name}</strong>, we received a request to reset your password.
+              Hi <strong>${safeName}</strong>, we received a request to reset your password.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/auth/forgot-password.js` at line 108, Escape the user-provided
name before inserting it into the HTML email template to prevent HTML injection:
add or reuse a small HTML-escaping helper (e.g., escapeHtml) and call it when
building the template instead of interpolating raw name; update the email
creation/site-mailing code in src/pages/api/auth/forgot-password.js (the
template that contains Hi <strong>${name}</strong>) to use escapeHtml(name) and
ensure the helper properly replaces &, <, >, ", and ' characters.

- Add password_reset_tokens table to schema
- POST /api/auth/forgot-password — rate-limited, no email enumeration
- POST /api/auth/reset-password — token hash validation, bcrypt, atomic tx
- /forgot-password page — email input with success/error feedback
- /reset-password page — new password + confirm with auto-redirect
- Add 'Forgot Password?' link to login page (existing-user mode)

Security: SHA-256 token hashing, 1hr expiry, one-time use, rate limiting
@Bhav-ikkk
Bhav-ikkk force-pushed the feat/forgot-password branch from 559ed7f to 9d30bc3 Compare March 30, 2026 09:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/pages/api/auth/reset-password.js (2)

32-34: Consider stronger password validation.

The current minimum of 6 characters is relatively weak. Additionally, bcrypt silently truncates passwords beyond 72 bytes. Consider:

  • Increasing minimum to 8+ characters (NIST SP 800-63B recommendation)
  • Adding a maximum length check (e.g., 128 chars) to prevent unexpected bcrypt truncation
  • Optionally checking against common password lists
Example enhancement
- if (!password || password.length < 6) {
-   return res.status(400).json({ error: 'Password must be at least 6 characters' })
+ if (!password || typeof password !== 'string') {
+   return res.status(400).json({ error: 'Password is required' })
+ }
+ if (password.length < 8 || password.length > 128) {
+   return res.status(400).json({ error: 'Password must be between 8 and 128 characters' })
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/auth/reset-password.js` around lines 32 - 34, Update the
password validation in the reset-password request handler to enforce stronger
limits: change the minimum length from 6 to 8 and add a maximum length check
(e.g., 128) to guard against bcrypt’s 72-byte truncation; in the same block
around the existing if (!password || password.length < 6) check, validate
password.length >= 8 && password.length <= 128 and return distinct 400 JSON
errors for too-short and too-long cases; optionally add a call to a
common-passwords check (e.g., isCommonPassword(password)) and return a 400 if it
matches, ensuring the error messages reference the specific validation that
failed.

63-77: Good use of transaction for atomicity.

The transaction correctly ensures the password update and token invalidation happen atomically.

Consider: Existing sessions remain valid after password reset.

Per the context in src/lib/nextAuthConfig.js, JWT-based sessions issued before the password reset will remain valid until they naturally expire. For security-sensitive applications, consider invalidating existing sessions when a password is reset (e.g., by storing a password_changed_at timestamp and checking it during token validation, or using a session revocation list).

This is a design decision and may be acceptable for your threat model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/auth/reset-password.js` around lines 63 - 77, The
reset-password transaction updates the password and marks tokens used but
doesn't invalidate existing JWT sessions; add an update inside the dbTx block to
set a password_changed_at (e.g., 'UPDATE users SET password_hash = $1,
password_changed_at = NOW(), updated_at = NOW() WHERE id = $2') so the database
records when the password changed, and then modify the session/jwt validation
logic in src/lib/nextAuthConfig.js to reject tokens issued before
users.password_changed_at (compare token.iat or token.issueTime against
password_changed_at) or implement a revocation check against that timestamp
during token validation.
src/pages/api/auth/forgot-password.js (1)

68-83: Consider wrapping token insert and email send in a transaction or reordering.

The token is persisted (lines 68-71) before sendMail is called (lines 78-83). Per src/lib/mailer.js:64-80, sendMail can throw on SMTP errors. If the email fails, an unused token remains in the database until expiry.

While not critical (orphan tokens expire and are harmless), this creates unnecessary database clutter and could confuse debugging. Consider either:

  1. Wrapping both operations in a transaction and rolling back on email failure, or
  2. Inserting the token only after successful email send (though this risks the reverse scenario).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/auth/forgot-password.js` around lines 68 - 83, Wrap the token
INSERT and sendMail call in a database transaction so that if sendMail (from
src/lib/mailer.js) throws, you rollback and avoid an orphan token; specifically,
begin a transaction before calling dbNone to INSERT into password_reset_tokens
(using the same DB client/transaction context), call
sendMail(buildResetEmailHtml(user.name, resetUrl), ...) inside that transaction,
and COMMIT only after sendMail succeeds, otherwise ROLLBACK on error and
rethrow; ensure you still generate rawToken/tokenHash as before and use the same
transaction context for the INSERT/rollback logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/pages/api/auth/forgot-password.js`:
- Around line 68-83: Wrap the token INSERT and sendMail call in a database
transaction so that if sendMail (from src/lib/mailer.js) throws, you rollback
and avoid an orphan token; specifically, begin a transaction before calling
dbNone to INSERT into password_reset_tokens (using the same DB
client/transaction context), call sendMail(buildResetEmailHtml(user.name,
resetUrl), ...) inside that transaction, and COMMIT only after sendMail
succeeds, otherwise ROLLBACK on error and rethrow; ensure you still generate
rawToken/tokenHash as before and use the same transaction context for the
INSERT/rollback logic.

In `@src/pages/api/auth/reset-password.js`:
- Around line 32-34: Update the password validation in the reset-password
request handler to enforce stronger limits: change the minimum length from 6 to
8 and add a maximum length check (e.g., 128) to guard against bcrypt’s 72-byte
truncation; in the same block around the existing if (!password ||
password.length < 6) check, validate password.length >= 8 && password.length <=
128 and return distinct 400 JSON errors for too-short and too-long cases;
optionally add a call to a common-passwords check (e.g.,
isCommonPassword(password)) and return a 400 if it matches, ensuring the error
messages reference the specific validation that failed.
- Around line 63-77: The reset-password transaction updates the password and
marks tokens used but doesn't invalidate existing JWT sessions; add an update
inside the dbTx block to set a password_changed_at (e.g., 'UPDATE users SET
password_hash = $1, password_changed_at = NOW(), updated_at = NOW() WHERE id =
$2') so the database records when the password changed, and then modify the
session/jwt validation logic in src/lib/nextAuthConfig.js to reject tokens
issued before users.password_changed_at (compare token.iat or token.issueTime
against password_changed_at) or implement a revocation check against that
timestamp during token validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 357df2c8-771c-4d42-8325-e03e583e70d5

📥 Commits

Reviewing files that changed from the base of the PR and between 559ed7f and 9d30bc3.

📒 Files selected for processing (7)
  • schema.sql
  • src/pages/api/auth/forgot-password.js
  • src/pages/api/auth/register.js
  • src/pages/api/auth/reset-password.js
  • src/pages/forgot-password/index.js
  • src/pages/login/index.js
  • src/pages/reset-password/index.js
✅ Files skipped from review due to trivial changes (3)
  • src/pages/api/auth/register.js
  • src/pages/login/index.js
  • schema.sql
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/pages/reset-password/index.js
  • src/pages/forgot-password/index.js

@Bhav-ikkk
Bhav-ikkk merged commit b4f0031 into main Mar 30, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants