[{"data":1,"prerenderedAt":347},["ShallowReactive",2],{"blog-how-to/password-reset-security":3},{"id":4,"title":5,"body":6,"category":328,"date":329,"dateModified":329,"description":330,"draft":331,"extension":332,"faq":333,"featured":331,"headerVariant":334,"image":333,"keywords":333,"meta":335,"navigation":336,"ogDescription":337,"ogTitle":333,"path":338,"readTime":333,"schemaOrg":339,"schemaType":340,"seo":341,"sitemap":342,"stem":343,"tags":344,"twitterCard":345,"__hash__":346},"blog/blog/how-to/password-reset-security.md","How to Implement Secure Password Reset",{"type":7,"value":8,"toc":312},"minimark",[9,13,17,21,27,30,43,48,51,55,75,88,101,114,127,140,153,188,192,226,230,235,238,242,245,249,252,274,293],[10,11],"category-badge",{"category":12},"How-To Guide",[14,15,5],"h1",{"id":16},"how-to-implement-secure-password-reset",[18,19,20],"p",{},"Prevent account takeover through password reset vulnerabilities",[22,23,24],"tldr",{},[18,25,26],{},"TL;DR (20 minutes):\nGenerate 32+ byte random tokens, hash before storing, 1-hour expiration max, single-use only. Always return \"if account exists, email sent\" to prevent enumeration. Invalidate all sessions after password change. Rate limit requests (3/hour per email). Send notification to old email after reset.",[18,28,29],{},"Prerequisites:",[31,32,33,37,40],"ul",{},[34,35,36],"li",{},"User authentication system",[34,38,39],{},"Email sending capability",[34,41,42],{},"Database for token storage",[44,45,47],"h2",{"id":46},"why-this-matters","Why This Matters",[18,49,50],{},"Password reset is a high-value target for attackers. Vulnerabilities in reset flows have led to countless account takeovers. Common issues: predictable tokens, no expiration, user enumeration, and missing session invalidation. This guide covers all the security requirements.",[44,52,54],{"id":53},"step-by-step-guide","Step-by-Step Guide",[56,57,59,64],"step",{"number":58},"1",[60,61,63],"h3",{"id":62},"database-schema","Database schema",[65,66,71],"pre",{"className":67,"code":69,"language":70},[68],"language-text","// Prisma schema\nmodel PasswordResetToken {\n  id        String   @id @default(cuid())\n  tokenHash String   @unique\n  userId    String\n  user      User     @relation(fields: [userId], references: [id])\n  expiresAt DateTime\n  usedAt    DateTime?\n  createdAt DateTime @default(now())\n  ipAddress String?\n  userAgent String?\n\n  @@index([userId])\n  @@index([expiresAt])\n}\n","text",[72,73,69],"code",{"__ignoreMap":74},"",[56,76,78,82],{"number":77},"2",[60,79,81],{"id":80},"generate-secure-reset-tokens","Generate secure reset tokens",[65,83,86],{"className":84,"code":85,"language":70},[68],"import crypto from 'crypto';\n\nfunction generateResetToken(): string {\n  return crypto.randomBytes(32).toString('hex');\n}\n\nfunction hashToken(token: string): string {\n  return crypto.createHash('sha256').update(token).digest('hex');\n}\n\nasync function createPasswordResetToken(\n  userId: string,\n  ipAddress?: string,\n  userAgent?: string\n) {\n  // Invalidate any existing tokens for this user\n  await prisma.passwordResetToken.updateMany({\n    where: {\n      userId,\n      usedAt: null,\n      expiresAt: { gt: new Date() }\n    },\n    data: { usedAt: new Date() }  // Mark as used\n  });\n\n  const token = generateResetToken();\n  const tokenHash = hashToken(token);\n\n  await prisma.passwordResetToken.create({\n    data: {\n      tokenHash,\n      userId,\n      expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour\n      ipAddress,\n      userAgent\n    }\n  });\n\n  return token;  // Return raw token for email link\n}\n",[72,87,85],{"__ignoreMap":74},[56,89,91,95],{"number":90},"3",[60,92,94],{"id":93},"request-password-reset-endpoint","Request password reset endpoint",[65,96,99],{"className":97,"code":98,"language":70},[68],"import { Ratelimit } from '@upstash/ratelimit';\n\nconst resetRateLimiter = new Ratelimit({\n  redis,\n  limiter: Ratelimit.slidingWindow(3, '1 h')  // 3 per hour\n});\n\nasync function requestPasswordReset(req, res) {\n  const { email } = req.body;\n\n  // Validate email format\n  if (!email || !isValidEmail(email)) {\n    return res.status(400).json({ error: 'Invalid email address' });\n  }\n\n  // Rate limit\n  const rateLimit = await resetRateLimiter.limit(email.toLowerCase());\n  if (!rateLimit.success) {\n    // Don't reveal rate limiting to prevent enumeration\n    return res.json({\n      message: 'If an account exists with this email, a reset link has been sent.'\n    });\n  }\n\n  // Look up user\n  const user = await prisma.user.findUnique({\n    where: { email: email.toLowerCase() }\n  });\n\n  // IMPORTANT: Same response whether user exists or not\n  if (user) {\n    const token = await createPasswordResetToken(\n      user.id,\n      req.ip,\n      req.headers['user-agent']\n    );\n\n    const resetLink = `${process.env.APP_URL}/reset-password?token=${token}`;\n\n    await sendPasswordResetEmail(user.email, resetLink, {\n      userName: user.name,\n      ipAddress: req.ip,\n      requestTime: new Date()\n    });\n  }\n\n  // Always return success (prevents enumeration)\n  return res.json({\n    message: 'If an account exists with this email, a reset link has been sent.'\n  });\n}\n",[72,100,98],{"__ignoreMap":74},[56,102,104,108],{"number":103},"4",[60,105,107],{"id":106},"send-secure-reset-email","Send secure reset email",[65,109,112],{"className":110,"code":111,"language":70},[68],"async function sendPasswordResetEmail(\n  email: string,\n  resetLink: string,\n  context: { userName?: string; ipAddress?: string; requestTime: Date }\n) {\n  await resend.emails.send({\n    from: 'MyApp Security ',\n    to: email,\n    subject: 'Reset your password - MyApp',\n    html: `\n      Password Reset Request\n\n      Hi${context.userName ? ` ${context.userName}` : ''},\n\n      We received a request to reset your password. Click the button below to create a new password:\n\n      \n        Reset Password\n      \n\n      This link expires in 1 hour.\n\n      \n\n      \n        Didn't request this?\n        If you didn't request a password reset, you can safely ignore this email.\n        Your password won't be changed unless you click the link above.\n      \n\n      \n        This request was made from IP address ${context.ipAddress || 'unknown'}\n        at ${context.requestTime.toISOString()}.\n        If this wasn't you, please secure your account.\n      \n\n      \n        Link not working? Copy and paste this URL:\n        ${resetLink}\n      \n    `\n  });\n}\n",[72,113,111],{"__ignoreMap":74},[56,115,117,121],{"number":116},"5",[60,118,120],{"id":119},"validate-token-and-reset-password","Validate token and reset password",[65,122,125],{"className":123,"code":124,"language":70},[68],"import { z } from 'zod';\nimport bcrypt from 'bcrypt';\n\nconst resetSchema = z.object({\n  token: z.string().min(1),\n  password: z.string()\n    .min(8, 'Password must be at least 8 characters')\n    .regex(/[a-z]/, 'Password must contain a lowercase letter')\n    .regex(/[A-Z]/, 'Password must contain an uppercase letter')\n    .regex(/[0-9]/, 'Password must contain a number')\n});\n\nasync function resetPassword(req, res) {\n  const result = resetSchema.safeParse(req.body);\n  if (!result.success) {\n    return res.status(400).json({ errors: result.error.flatten() });\n  }\n\n  const { token, password } = result.data;\n  const tokenHash = hashToken(token);\n\n  // Find valid token\n  const resetToken = await prisma.passwordResetToken.findUnique({\n    where: { tokenHash },\n    include: { user: true }\n  });\n\n  // Check token exists\n  if (!resetToken) {\n    return res.status(400).json({ error: 'Invalid or expired reset link' });\n  }\n\n  // Check not already used\n  if (resetToken.usedAt) {\n    return res.status(400).json({ error: 'This reset link has already been used' });\n  }\n\n  // Check not expired\n  if (resetToken.expiresAt \u003C new Date()) {\n    return res.status(400).json({ error: 'This reset link has expired' });\n  }\n\n  // Hash new password\n  const passwordHash = await bcrypt.hash(password, 12);\n\n  // Update password and invalidate token in transaction\n  await prisma.$transaction([\n    prisma.user.update({\n      where: { id: resetToken.userId },\n      data: {\n        passwordHash,\n        passwordChangedAt: new Date()\n      }\n    }),\n    prisma.passwordResetToken.update({\n      where: { id: resetToken.id },\n      data: { usedAt: new Date() }\n    }),\n    // Invalidate ALL sessions for this user\n    prisma.session.deleteMany({\n      where: { userId: resetToken.userId }\n    })\n  ]);\n\n  // Send confirmation email\n  await sendPasswordChangedEmail(resetToken.user.email);\n\n  return res.json({\n    success: true,\n    message: 'Password has been reset. Please log in with your new password.'\n  });\n}\n",[72,126,124],{"__ignoreMap":74},[56,128,130,134],{"number":129},"6",[60,131,133],{"id":132},"send-password-changed-notification","Send password changed notification",[65,135,138],{"className":136,"code":137,"language":70},[68],"async function sendPasswordChangedEmail(email: string) {\n  await resend.emails.send({\n    from: 'MyApp Security ',\n    to: email,\n    subject: 'Your password has been changed - MyApp',\n    html: `\n      Password Changed\n\n      Your password was successfully changed. You can now log in with your new password.\n\n      For security, all your other sessions have been logged out.\n\n      \n\n      \n        Didn't change your password?\n        If you didn't make this change, your account may be compromised.\n        Please reset your password immediately\n        and contact our support team.\n      \n    `\n  });\n}\n",[72,139,137],{"__ignoreMap":74},[56,141,143,147],{"number":142},"7",[60,144,146],{"id":145},"clean-up-expired-tokens","Clean up expired tokens",[65,148,151],{"className":149,"code":150,"language":70},[68],"// Run periodically\nasync function cleanupExpiredTokens() {\n  await prisma.passwordResetToken.deleteMany({\n    where: {\n      OR: [\n        { expiresAt: { lt: new Date() } },\n        { usedAt: { not: null } }\n      ]\n    }\n  });\n}\n",[72,152,150],{"__ignoreMap":74},[154,155,156,159],"warning-box",{},[18,157,158],{},"Password Reset Security Checklist:",[31,160,161,164,167,170,173,176,179,182,185],{},[34,162,163],{},"Use cryptographically secure random tokens (32+ bytes)",[34,165,166],{},"Hash tokens before storing (attacker with DB access can't use them)",[34,168,169],{},"Short expiration (1 hour max)",[34,171,172],{},"Single use - invalidate immediately after use",[34,174,175],{},"Same response for valid/invalid emails (prevent enumeration)",[34,177,178],{},"Invalidate all sessions after password change",[34,180,181],{},"Send notification to the account after reset",[34,183,184],{},"Rate limit reset requests",[34,186,187],{},"Include security context in emails (IP, time)",[44,189,191],{"id":190},"how-to-verify-it-worked","How to Verify It Worked",[193,194,195,202,208,214,220],"ol",{},[34,196,197,201],{},[198,199,200],"strong",{},"Test token security:"," Try using a token twice - should fail",[34,203,204,207],{},[198,205,206],{},"Test expiration:"," Wait 1+ hour, try token - should fail",[34,209,210,213],{},[198,211,212],{},"Test enumeration:"," Request reset for non-existent email - same response as valid",[34,215,216,219],{},[198,217,218],{},"Test session invalidation:"," Reset password, verify other sessions logged out",[34,221,222,225],{},[198,223,224],{},"Test notification:"," Verify email sent after password change",[44,227,229],{"id":228},"common-errors-troubleshooting","Common Errors & Troubleshooting",[231,232,234],"h4",{"id":233},"users-not-receiving-reset-emails","Users not receiving reset emails",[18,236,237],{},"Check spam folders, verify domain authentication (SPF, DKIM), check email service logs.",[231,239,241],{"id":240},"token-expired-too-quickly","Token expired too quickly",[18,243,244],{},"Check server time synchronization. Consider extending to 2-4 hours if users consistently have issues.",[231,246,248],{"id":247},"rate-limiting-blocking-legitimate-users","Rate limiting blocking legitimate users",[18,250,251],{},"3/hour might be too strict. Adjust based on your user behavior, but don't go above 5-10/hour.",[253,254,255,262,268],"faq-section",{},[256,257,259],"faq-item",{"question":258},"Should I require the old password to reset?",[18,260,261],{},"No - password reset is for when users forgot their password. For changing password while logged in, yes, require the current password. These are different flows.",[256,263,265],{"question":264},"What about security questions?",[18,266,267],{},"Security questions are generally not recommended - they're often guessable or available through social engineering. Email-based reset with proper token security is better.",[256,269,271],{"question":270},"Should I send the new password in email?",[18,272,273],{},"Never! Email isn't secure. Always have the user create their own password through your secure form.",[18,275,276,279,284,285,284,289],{},[198,277,278],{},"Related guides:",[280,281,283],"a",{"href":282},"/blog/how-to/hash-passwords-securely","Hash Passwords Securely"," ·\n",[280,286,288],{"href":287},"/blog/how-to/session-management","Session Management",[280,290,292],{"href":291},"/blog/how-to/magic-links","Magic Links",[294,295,296,302,307],"related-articles",{},[297,298],"related-card",{"description":299,"href":300,"title":301},"Step-by-step guide to secure form validation. Client and server-side validation, CSRF protection, honeypots for bot dete","/blog/how-to/form-validation","How to Implement Secure Form Validation",[297,303],{"description":304,"href":305,"title":306},"Complete guide to GitHub Secrets for GitHub Actions. Store API keys, access tokens, and sensitive data securely in your ","/blog/how-to/github-secrets","How to Use GitHub Secrets for Actions",[297,308],{"description":309,"href":310,"title":311},"Prevent accidental commits of API keys, .env files, and credentials. Complete guide to configuring .gitignore for sensit","/blog/how-to/gitignore-secrets","How to Gitignore Sensitive Files",{"title":74,"searchDepth":313,"depth":313,"links":314},2,[315,316,326,327],{"id":46,"depth":313,"text":47},{"id":53,"depth":313,"text":54,"children":317},[318,320,321,322,323,324,325],{"id":62,"depth":319,"text":63},3,{"id":80,"depth":319,"text":81},{"id":93,"depth":319,"text":94},{"id":106,"depth":319,"text":107},{"id":119,"depth":319,"text":120},{"id":132,"depth":319,"text":133},{"id":145,"depth":319,"text":146},{"id":190,"depth":313,"text":191},{"id":228,"depth":313,"text":229},"how-to","2026-01-20","Step-by-step guide to implementing secure password reset flows. Prevent account takeover, token attacks, and enumeration vulnerabilities.",false,"md",null,"yellow",{},true,"Build a secure password reset flow that prevents account takeover.","/blog/how-to/password-reset-security","[object Object]","HowTo",{"title":5,"description":330},{"loc":338},"blog/how-to/password-reset-security",[],"summary_large_image","IKgnAs6UFLWlcO_zB8tDzlomi1MKRE2OCG9SUwANeso",1775843928154]