[{"data":1,"prerenderedAt":19},["ShallowReactive",2],{"blog-build-password-reset-flow":3},{"slug":4,"title":5,"description":6,"date":7,"author":8,"image":12,"tags":13,"content":17,"readingTime":18},"build-password-reset-flow","How to build a password reset flow with transactional email","A complete guide to building secure password reset flows — token generation, email sending, expiration handling, and security best practices.","2026-03-31",{"id":9,"name":10,"avatar":11},"vanessalozzardo","Vanessa Lozzardo","/images/authors/vanessa.png","/images/blog/build-password-reset-flow/cover.jpg",[14,15,16],"transactional-email","tutorial","security","\u003Cp>Password reset is the single most security-critical email your application sends. Get it wrong and you hand attackers a backdoor. Get it right and users barely think about it — they click a link, set a new password, and move on.\u003C/p>\n\u003Cp>This guide covers the entire password reset flow: token generation, secure storage, email delivery with \u003Ca href=\"/email-api\">Sendkit\u003C/a>, verification, and the mistakes that get teams breached.\u003C/p>\n\u003Ch2>The flow\u003C/h2>\n\u003Cp>Every password reset follows the same sequence:\u003C/p>\n\u003Col>\n\u003Cli>User submits their email on your &quot;Forgot password&quot; form\u003C/li>\n\u003Cli>Your server generates a cryptographically random token\u003C/li>\n\u003Cli>You hash the token and store it with an expiration timestamp\u003C/li>\n\u003Cli>You send the unhashed token in a reset link via email\u003C/li>\n\u003Cli>User clicks the link\u003C/li>\n\u003Cli>Your server hashes the incoming token, looks it up, checks expiration\u003C/li>\n\u003Cli>User sets a new password\u003C/li>\n\u003Cli>You invalidate the token\u003C/li>\n\u003C/ol>\n\u003Cp>Each step has security implications. Skip one and you create a vulnerability.\u003C/p>\n\u003Ch2>Generate a secure token\u003C/h2>\n\u003Cp>The token is the core of the entire flow. It must be unpredictable.\u003C/p>\n\u003Cpre class=\"shiki dracula\" style=\"background-color:#282A36;color:#F8F8F2\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">import\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> crypto \u003C/span>\u003Cspan style=\"color:#FF79C6\">from\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">node:crypto\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">const\u003C/span>\u003Cspan style=\"color:#50FA7B\"> generateResetToken\u003C/span>\u003Cspan style=\"color:#FF79C6\"> =\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> () \u003C/span>\u003Cspan style=\"color:#FF79C6\">=>\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> token \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> crypto.\u003C/span>\u003Cspan style=\"color:#50FA7B\">randomBytes\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#BD93F9\">32\u003C/span>\u003Cspan style=\"color:#F8F8F2\">).\u003C/span>\u003Cspan style=\"color:#50FA7B\">toString\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">hex\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> hash \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> crypto.\u003C/span>\u003Cspan style=\"color:#50FA7B\">createHash\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">sha256\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">).\u003C/span>\u003Cspan style=\"color:#50FA7B\">update\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(token).\u003C/span>\u003Cspan style=\"color:#50FA7B\">digest\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">hex\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> expiresAt \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#FF79C6;font-weight:bold\"> new\u003C/span>\u003Cspan style=\"color:#50FA7B\"> Date\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(Date.\u003C/span>\u003Cspan style=\"color:#50FA7B\">now\u003C/span>\u003Cspan style=\"color:#F8F8F2\">() \u003C/span>\u003Cspan style=\"color:#FF79C6\">+\u003C/span>\u003Cspan style=\"color:#BD93F9\"> 20\u003C/span>\u003Cspan style=\"color:#FF79C6\"> *\u003C/span>\u003Cspan style=\"color:#BD93F9\"> 60\u003C/span>\u003Cspan style=\"color:#FF79C6\"> *\u003C/span>\u003Cspan style=\"color:#BD93F9\"> 1000\u003C/span>\u003Cspan style=\"color:#F8F8F2\">); \u003C/span>\u003Cspan style=\"color:#6272A4\">// 20 minutes\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  return\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> { token, hash, expiresAt };\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">};\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\u003Cp>Use \u003Ccode>crypto.randomBytes\u003C/code>, not \u003Ccode>Math.random\u003C/code>. The difference matters: \u003Ccode>Math.random\u003C/code> is not cryptographically secure. An attacker who knows the internal state can predict future outputs. \u003Ccode>crypto.randomBytes\u003C/code> pulls from the OS entropy pool and produces values that are computationally infeasible to guess.\u003C/p>\n\u003Cp>Hash the token with SHA-256 before storing it in your database. Store only the hash. If your database leaks, attackers get hashes — not usable tokens.\u003C/p>\n\u003Cp>\u003Cimg src=\"/images/blog/build-password-reset-flow/inline-1.jpg\" alt=\"Secure token generation and storage flow\" width=\"1080\" height=\"720\" loading=\"lazy\" />\u003C/p>\n\u003Ch2>Store the token with an expiration\u003C/h2>\n\u003Cp>Your database record should look like this:\u003C/p>\n\u003Cpre class=\"shiki dracula\" style=\"background-color:#282A36;color:#F8F8F2\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">const\u003C/span>\u003Cspan style=\"color:#50FA7B\"> storeResetToken\u003C/span>\u003Cspan style=\"color:#FF79C6\"> =\u003C/span>\u003Cspan style=\"color:#FF79C6\"> async\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (\u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">userId\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">hash\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">expiresAt\u003C/span>\u003Cspan style=\"color:#F8F8F2\">) \u003C/span>\u003Cspan style=\"color:#FF79C6\">=>\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6272A4\">  // Invalidate any existing tokens for this user first\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.passwordResets.\u003C/span>\u003Cspan style=\"color:#50FA7B\">deleteMany\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ userId });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.passwordResets.\u003C/span>\u003Cspan style=\"color:#50FA7B\">create\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    userId,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    tokenHash\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> hash,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    expiresAt,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    used\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#BD93F9\"> false\u003C/span>\u003Cspan style=\"color:#F8F8F2\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">};\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\u003Cp>Set expiration between 15 and 30 minutes. Anything longer gives attackers a wider window. Anything shorter frustrates users who check email on their phone. Twenty minutes is the sweet spot.\u003C/p>\n\u003Cp>Always delete existing tokens before creating a new one. One active token per user at a time. No exceptions.\u003C/p>\n\u003Ch2>Rate limit reset requests\u003C/h2>\n\u003Cp>Without rate limiting, an attacker can flood any email address with reset emails or brute-force tokens. Add limits at two levels:\u003C/p>\n\u003Cpre class=\"shiki dracula\" style=\"background-color:#282A36;color:#F8F8F2\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">import\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> rateLimit \u003C/span>\u003Cspan style=\"color:#FF79C6\">from\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">express-rate-limit\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetLimiter \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#50FA7B\"> rateLimit\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  windowMs\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#BD93F9\"> 15\u003C/span>\u003Cspan style=\"color:#FF79C6\"> *\u003C/span>\u003Cspan style=\"color:#BD93F9\"> 60\u003C/span>\u003Cspan style=\"color:#FF79C6\"> *\u003C/span>\u003Cspan style=\"color:#BD93F9\"> 1000\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#6272A4\">// 15 minutes\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  max\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#BD93F9\"> 3\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#6272A4\">// 3 requests per window per IP\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  standardHeaders\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#BD93F9\"> true\u003C/span>\u003Cspan style=\"color:#F8F8F2\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  legacyHeaders\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#BD93F9\"> false\u003C/span>\u003Cspan style=\"color:#F8F8F2\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">});\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">app.\u003C/span>\u003Cspan style=\"color:#50FA7B\">post\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">/api/forgot-password\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, resetLimiter, \u003C/span>\u003Cspan style=\"color:#FF79C6\">async\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (\u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">req\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">res\u003C/span>\u003Cspan style=\"color:#F8F8F2\">) \u003C/span>\u003Cspan style=\"color:#FF79C6\">=>\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> { email } \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> req.body;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6272A4\">  // Always return the same response regardless of whether the email exists\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  res.\u003C/span>\u003Cspan style=\"color:#50FA7B\">json\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ message\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">If that email is registered, a reset link has been sent.\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> user \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#FF79C6\"> await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.users.\u003C/span>\u003Cspan style=\"color:#50FA7B\">findByEmail\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(email);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  if\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (\u003C/span>\u003Cspan style=\"color:#FF79C6\">!\u003C/span>\u003Cspan style=\"color:#F8F8F2\">user) \u003C/span>\u003Cspan style=\"color:#FF79C6\">return\u003C/span>\u003Cspan style=\"color:#F8F8F2\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> { token, hash, expiresAt } \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#50FA7B\"> generateResetToken\u003C/span>\u003Cspan style=\"color:#F8F8F2\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  await\u003C/span>\u003Cspan style=\"color:#50FA7B\"> storeResetToken\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(user.id, hash, expiresAt);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  await\u003C/span>\u003Cspan style=\"color:#50FA7B\"> sendResetEmail\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(user.email, token);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">});\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\u003Cp>Two things to note here. First, the response is identical whether the email exists or not. This prevents account enumeration — attackers cannot use your reset endpoint to discover which emails are registered. Second, the actual work happens after the response. The user sees a generic confirmation either way.\u003C/p>\n\u003Ch2>Send the reset email with Sendkit\u003C/h2>\n\u003Cp>The reset email must arrive fast. If it takes two minutes, users assume it failed and submit again. Transactional email infrastructure like \u003Ca href=\"/email-api\">Sendkit\u003C/a> is built for this — delivery in seconds, not minutes.\u003C/p>\n\u003Cp>Install the SDK:\u003C/p>\n\u003Cpre class=\"shiki dracula\" style=\"background-color:#282A36;color:#F8F8F2\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#50FA7B\">npm\u003C/span>\u003Cspan style=\"color:#F1FA8C\"> install\u003C/span>\u003Cspan style=\"color:#F1FA8C\"> @sendkitdev/sdk\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\u003Cp>Build the send function:\u003C/p>\n\u003Cpre class=\"shiki dracula\" style=\"background-color:#282A36;color:#F8F8F2\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">import\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> { Sendkit } \u003C/span>\u003Cspan style=\"color:#FF79C6\">from\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">@sendkitdev/sdk\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> sendkit \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#FF79C6;font-weight:bold\"> new\u003C/span>\u003Cspan style=\"color:#50FA7B\"> Sendkit\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">sk_live_your_api_key\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">const\u003C/span>\u003Cspan style=\"color:#50FA7B\"> sendResetEmail\u003C/span>\u003Cspan style=\"color:#FF79C6\"> =\u003C/span>\u003Cspan style=\"color:#FF79C6\"> async\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (\u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">email\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">token\u003C/span>\u003Cspan style=\"color:#F8F8F2\">) \u003C/span>\u003Cspan style=\"color:#FF79C6\">=>\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetUrl \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#F1FA8C\"> `https://yourapp.com/reset-password?token=\u003C/span>\u003Cspan style=\"color:#FF79C6\">${\u003C/span>\u003Cspan style=\"color:#F8F8F2\">token\u003C/span>\u003Cspan style=\"color:#FF79C6\">}\u003C/span>\u003Cspan style=\"color:#F1FA8C\">`\u003C/span>\u003Cspan style=\"color:#F8F8F2\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> { data, error } \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#FF79C6\"> await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> sendkit.emails.\u003C/span>\u003Cspan style=\"color:#50FA7B\">send\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    from\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">security@yourapp.com\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    to\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> email,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    subject\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">Reset your password\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    html\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F1FA8C\"> `\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">      &#x3C;div style=\"font-family: sans-serif; max-width: 480px; margin: 0 auto;\">\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;h2>Password reset request&#x3C;/h2>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;p>We received a request to reset the password for the account associated with &#x3C;strong>\u003C/span>\u003Cspan style=\"color:#FF79C6\">${\u003C/span>\u003Cspan style=\"color:#F8F8F2\">email\u003C/span>\u003Cspan style=\"color:#FF79C6\">}\u003C/span>\u003Cspan style=\"color:#F1FA8C\">&#x3C;/strong>.&#x3C;/p>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;p>Click the button below to choose a new password:&#x3C;/p>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;a href=\"\u003C/span>\u003Cspan style=\"color:#FF79C6\">${\u003C/span>\u003Cspan style=\"color:#F8F8F2\">resetUrl\u003C/span>\u003Cspan style=\"color:#FF79C6\">}\u003C/span>\u003Cspan style=\"color:#F1FA8C\">\" style=\"display: inline-block; background: #1a1a2e; color: #ffffff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;\">\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">          Reset password\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;/a>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;p style=\"margin-top: 24px; font-size: 14px; color: #666;\">\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">          This link expires in 20 minutes. If you didn't request this, ignore this email — your password won't change.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;/p>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;p style=\"font-size: 12px; color: #999;\">\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">          If the button doesn't work, copy and paste this URL into your browser:&#x3C;br/>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">          &#x3C;a href=\"\u003C/span>\u003Cspan style=\"color:#FF79C6\">${\u003C/span>\u003Cspan style=\"color:#F8F8F2\">resetUrl\u003C/span>\u003Cspan style=\"color:#FF79C6\">}\u003C/span>\u003Cspan style=\"color:#F1FA8C\">\" style=\"color: #999;\">\u003C/span>\u003Cspan style=\"color:#FF79C6\">${\u003C/span>\u003Cspan style=\"color:#F8F8F2\">resetUrl\u003C/span>\u003Cspan style=\"color:#FF79C6\">}\u003C/span>\u003Cspan style=\"color:#F1FA8C\">&#x3C;/a>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">        &#x3C;/p>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">      &#x3C;/div>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F1FA8C\">    `\u003C/span>\u003Cspan style=\"color:#F8F8F2\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  if\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (error) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    console.\u003C/span>\u003Cspan style=\"color:#50FA7B\">error\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">Failed to send reset email:\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, error.message);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">    throw\u003C/span>\u003Cspan style=\"color:#FF79C6;font-weight:bold\"> new\u003C/span>\u003Cspan style=\"color:#50FA7B\"> Error\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">Email delivery failed\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  return\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> data.id;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">};\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\u003Cp>The email content follows a few deliberate patterns. It shows which account the reset is for so users with multiple accounts know which one triggered it. The CTA button is large and obvious. The expiration is stated clearly. There is a plaintext URL fallback for email clients that strip buttons. And the &quot;if you didn&#39;t request this&quot; line reduces support tickets.\u003C/p>\n\u003Cp>Never log the token or the full reset URL. If your logs are compromised, every pending reset becomes exploitable. Log the Sendkit message ID (\u003Ccode>data.id\u003C/code>) instead — that gives you enough to debug delivery issues.\u003C/p>\n\u003Cp>For more on structuring transactional emails, see our guide on \u003Ca href=\"/blog/send-transactional-email-nodejs\">sending transactional email with Node.js\u003C/a>.\u003C/p>\n\u003Ch2>Verify the token and reset the password\u003C/h2>\n\u003Cp>When the user clicks the link and submits a new password, verify the token:\u003C/p>\n\u003Cpre class=\"shiki dracula\" style=\"background-color:#282A36;color:#F8F8F2\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">app.\u003C/span>\u003Cspan style=\"color:#50FA7B\">post\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">/api/reset-password\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#FF79C6\">async\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (\u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">req\u003C/span>\u003Cspan style=\"color:#F8F8F2\">, \u003C/span>\u003Cspan style=\"color:#FFB86C;font-style:italic\">res\u003C/span>\u003Cspan style=\"color:#F8F8F2\">) \u003C/span>\u003Cspan style=\"color:#FF79C6\">=>\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> { token, newPassword } \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> req.body;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> tokenHash \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> crypto.\u003C/span>\u003Cspan style=\"color:#50FA7B\">createHash\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">sha256\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">).\u003C/span>\u003Cspan style=\"color:#50FA7B\">update\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(token).\u003C/span>\u003Cspan style=\"color:#50FA7B\">digest\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F1FA8C\">hex\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetRecord \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#FF79C6\"> await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.passwordResets.\u003C/span>\u003Cspan style=\"color:#50FA7B\">findOne\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    tokenHash,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">    used\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#BD93F9\"> false\u003C/span>\u003Cspan style=\"color:#F8F8F2\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  if\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (\u003C/span>\u003Cspan style=\"color:#FF79C6\">!\u003C/span>\u003Cspan style=\"color:#F8F8F2\">resetRecord) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">    return\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> res.\u003C/span>\u003Cspan style=\"color:#50FA7B\">status\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#BD93F9\">400\u003C/span>\u003Cspan style=\"color:#F8F8F2\">).\u003C/span>\u003Cspan style=\"color:#50FA7B\">json\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ error\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">Invalid or expired token\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  if\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> (\u003C/span>\u003Cspan style=\"color:#FF79C6;font-weight:bold\">new\u003C/span>\u003Cspan style=\"color:#50FA7B\"> Date\u003C/span>\u003Cspan style=\"color:#F8F8F2\">() \u003C/span>\u003Cspan style=\"color:#FF79C6\">>\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetRecord.expiresAt) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">    await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.passwordResets.\u003C/span>\u003Cspan style=\"color:#50FA7B\">delete\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ id\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetRecord.id });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">    return\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> res.\u003C/span>\u003Cspan style=\"color:#50FA7B\">status\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(\u003C/span>\u003Cspan style=\"color:#BD93F9\">400\u003C/span>\u003Cspan style=\"color:#F8F8F2\">).\u003C/span>\u003Cspan style=\"color:#50FA7B\">json\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ error\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">Invalid or expired token\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  const\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> hashedPassword \u003C/span>\u003Cspan style=\"color:#FF79C6\">=\u003C/span>\u003Cspan style=\"color:#FF79C6\"> await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> bcrypt.\u003C/span>\u003Cspan style=\"color:#50FA7B\">hash\u003C/span>\u003Cspan style=\"color:#F8F8F2\">(newPassword, \u003C/span>\u003Cspan style=\"color:#BD93F9\">12\u003C/span>\u003Cspan style=\"color:#F8F8F2\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.users.\u003C/span>\u003Cspan style=\"color:#50FA7B\">update\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ id\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetRecord.userId }, { password\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> hashedPassword });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6272A4\">  // Invalidate the token\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.passwordResets.\u003C/span>\u003Cspan style=\"color:#50FA7B\">update\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ id\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetRecord.id }, { used\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#BD93F9\"> true\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6272A4\">  // Invalidate all existing sessions for this user\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FF79C6\">  await\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> db.sessions.\u003C/span>\u003Cspan style=\"color:#50FA7B\">deleteMany\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ userId\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> resetRecord.userId });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">  res.\u003C/span>\u003Cspan style=\"color:#50FA7B\">json\u003C/span>\u003Cspan style=\"color:#F8F8F2\">({ message\u003C/span>\u003Cspan style=\"color:#FF79C6\">:\u003C/span>\u003Cspan style=\"color:#E9F284\"> '\u003C/span>\u003Cspan style=\"color:#F1FA8C\">Password updated successfully\u003C/span>\u003Cspan style=\"color:#E9F284\">'\u003C/span>\u003Cspan style=\"color:#F8F8F2\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F8F8F2\">});\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\u003Cp>Hash the incoming token the same way you hashed it during generation. Look up the hash, not the raw token. Check expiration server-side — never trust client-side timers.\u003C/p>\n\u003Cp>After a successful reset, invalidate all existing sessions. If an attacker had access to the account, you want to kick them out immediately.\u003C/p>\n\u003Cp>\u003Cimg src=\"/images/blog/build-password-reset-flow/inline-2.jpg\" alt=\"Password reset verification and session invalidation\" width=\"1080\" height=\"720\" loading=\"lazy\" />\u003C/p>\n\u003Ch2>Common mistakes that get you breached\u003C/h2>\n\u003Cp>\u003Cstrong>Predictable tokens.\u003C/strong> Using auto-incrementing IDs, UUIDs v1 (which are time-based), or short numeric codes. An attacker can enumerate or brute-force these. Use 32+ bytes of \u003Ccode>crypto.randomBytes\u003C/code>.\u003C/p>\n\u003Cp>\u003Cstrong>No expiration.\u003C/strong> A token that never expires is a permanent backdoor sitting in someone&#39;s inbox. Set 15-30 minutes and enforce it.\u003C/p>\n\u003Cp>\u003Cstrong>Storing raw tokens.\u003C/strong> If your database leaks and you stored unhashed tokens, every pending reset is compromised. Always store hashes.\u003C/p>\n\u003Cp>\u003Cstrong>Leaking tokens in logs.\u003C/strong> Your logging middleware captures request bodies or URLs that contain the token. Now your log aggregator has a copy of every reset token. Exclude sensitive fields explicitly.\u003C/p>\n\u003Cp>\u003Cstrong>No rate limiting.\u003C/strong> Without it, an attacker can request thousands of resets per second, flooding a user&#39;s inbox or brute-forcing tokens. Three requests per 15 minutes per IP is reasonable.\u003C/p>\n\u003Cp>\u003Cstrong>Account enumeration.\u003C/strong> Responding differently when an email exists vs. doesn&#39;t (&quot;We sent a reset link&quot; vs. &quot;Email not found&quot;) tells attackers which accounts are valid. Always return the same message.\u003C/p>\n\u003Cp>\u003Cstrong>Not invalidating sessions.\u003C/strong> The user resets their password because they suspect compromise. If you don&#39;t kill existing sessions, the attacker stays logged in.\u003C/p>\n\u003Ch2>Why transactional email infrastructure matters here\u003C/h2>\n\u003Cp>Password resets are time-sensitive. A user who waits more than 30 seconds for the email will try again, creating confusion with multiple tokens. If the email lands in spam, they may never complete the reset and will contact support instead.\u003C/p>\n\u003Cp>This is exactly why you need purpose-built transactional email delivery. Marketing email services batch-send and optimize for throughput. Transactional services like Sendkit optimize for speed and inbox placement. Your reset email should arrive within seconds, every time.\u003C/p>\n\u003Cp>Proper email authentication (SPF, DKIM, DMARC) also matters here. If your domain isn&#39;t authenticated, reset emails are more likely to be flagged. We cover this in detail in our \u003Ca href=\"/blog/improve-email-deliverability\">email deliverability guide\u003C/a>.\u003C/p>\n\u003Ch2>Wrapping up\u003C/h2>\n\u003Cp>A password reset flow is straightforward to build but easy to get wrong in ways that create real security vulnerabilities. Use cryptographically random tokens, hash before storing, enforce short expiration windows, rate limit requests, and never leak tokens in logs or error messages.\u003C/p>\n\u003Cp>For the email delivery piece, \u003Ca href=\"https://docs.sendkit.dev\">Sendkit\u003C/a> handles the hard part — fast, reliable delivery that lands in the inbox, not spam. Check our \u003Ca href=\"/pricing\">pricing\u003C/a> to get started, or read the \u003Ca href=\"https://docs.sendkit.dev\">docs\u003C/a> for the full SDK reference.\u003C/p>\n",8,1775845590154]