sodcalculator

10 Tech How-To

Secure Localized Password Reset Email: 6 Controls

Guide 10-38588 · Updated · Step by step

This guide walks through building a password reset email system that is both secure and localized for your users. You will learn six essential controls that prevent account takeover, minimize email abuse, and deliver a clear experience in the user’s language. You will need basic familiarity with your backend framework, an email delivery service, and a translation mechanism (like gettext or a locale dictionary).

What You’ll Need

  • A backend application (Node.js, Python, PHP, etc.) with user authentication
  • An email sending service or SMTP credentials
  • A database to store reset tokens and expiry timestamps
  • Access to your user table (to store a reset token hash and timestamp)
  • A localization system (e.g., i18n library, locale JSON files) already set up

Step-by-Step

  1. Generate a cryptographically secure token. Use a random byte generator from your language’s crypto library (e.g., crypto.randomBytes(32) in Node.js, secrets.token_urlsafe(32) in Python, random_bytes(32) in PHP). This produces a 256-bit token that is practically impossible to guess. Do not use rand() or Math.random(); these are not secure.
  2. Store only a hash of the token. Never store the raw token in your database. If your database leaks, attackers get usable tokens. Instead, store a SHA-256 hash of the token (or better, a password hash like bcrypt/argon2). When the user clicks the link, hash the submitted token and compare it to the stored hash. This is the first essential control: token hashing at rest.
  3. Set a short expiry and enforce it. Store an expires_at timestamp with the hash. A common good default is 30 minutes. When the reset link is clicked, check the current time against the expiry. If expired, reject the request and prompt the user to request a new link. This limits the window of opportunity for an attacker who intercepts the email. This is the second control: time-limited tokens.
  4. Invalidate the token after one use. As soon as a password reset is successfully completed, delete the token record (or mark it used). If the same link is clicked again, it must fail. This prevents replay attacks and ensures that a stolen link cannot be reused after the user has already changed their password. This is the third control: single-use enforcement.
  5. Localize the email content. Before sending, detect the user’s preferred locale. This is usually stored on the user profile (e.g., locale = "fr" or "pt-BR"). If not available, fall back to the Accept-Language header from the request that triggered the reset, then to a default language. Use your i18n system to render the subject line and body in that locale. Include the localized token link, and also localize the expiry warning (e.g., “This link expires in 30 minutes”). This is the fourth control: locale-aware content.
  6. Add rate limiting and abuse prevention. Before sending any reset email, check how many reset requests have been made for that email address in the last hour (e.g., max 3). Also rate-limit by IP address (e.g., max 5 per hour). If the limit is exceeded, do not send the email; instead, show a generic message like “Too many requests. Try again later.” This prevents attackers from flooding a user’s inbox or using the endpoint to enumerate valid email addresses. This is the fifth control: rate limiting.
  7. Send a generic response regardless of whether the email exists. When a user submits a reset request, always respond with the same message: “If an account exists for that email, a reset link has been sent.” Do not reveal whether the email is registered. This prevents user enumeration. This is the sixth control: anti-enumeration response.

Common Mistakes

  • Storing the raw token in the database. This is the most critical mistake. If your DB is compromised, all pending reset links are exposed. Always hash the token.
  • Using a long expiry (e.g., 24 hours or 7 days). Longer expiries increase the window for an attacker to use a stolen link. 30 minutes is a good balance.
  • Not invalidating after use. If the token remains valid, a user who clicks the link twice (or an attacker who intercepts it) can reset the password again after the user has changed it.
  • Ignoring the locale entirely. Sending an English email to a French user reduces trust and may cause the user to ignore the email or mark it as spam.
  • Revealing whether an email exists. If you say “Email not found” vs. “Email sent,” you give attackers a way to harvest valid addresses.
  • Not rate limiting. Without limits, an attacker can trigger hundreds of reset emails to a victim, filling their inbox and potentially locking them out if you also lock the account after too many attempts.

Tips

  • Use a dedicated reset page URL that includes the token as a path parameter or query string (e.g., https://example.com/reset?token=abc123). Do not put the token in the email body as plain text that could be copied incorrectly.
  • Log reset events (request, success, failure) with timestamps and IP addresses. This helps detect abuse and troubleshoot issues.
  • Include a “didn’t request this?” note in the email, localized, telling the user to ignore the email and optionally to change their password if they suspect compromise.
  • Consider using a separate email template for each locale rather than trying to translate inline. This makes it easier to adjust layout for different text lengths.
  • Test the flow in every supported locale. Some languages have much longer words; ensure the email layout does not break.
  • Add a CAPTCHA or a similar challenge on the “forgot password” form if you are concerned about automated abuse, especially if you cannot rely on IP-based rate limiting (e.g., behind a shared proxy).

Troubleshooting

  • Link says “invalid or expired” immediately. Check that the token was hashed correctly when stored and when compared. Also check the server’s clock is correct (NTP sync) — if the server time is ahead of real time, the link will appear expired.
  • Email never arrives. Check your email service logs for bounces or spam filtering. Verify the “from” address is allowed by your domain’s SPF/DKIM records. Also check that the template for the user’s locale actually rendered (a missing translation key can cause the email to fail to send).
  • User reports the link only works once. This is expected behavior. If you want to allow multiple uses within the expiry (e.g., user opens on phone and then laptop), you need to decide on a policy. The secure default is single-use; if you want multi-use, you must shorten the expiry (e.g., 10 minutes) and still invalidate after the password is changed.
  • Rate limiter blocks legitimate users. If a user is behind a corporate NAT, many users share one IP. You may need to rate-limit by email address only, or use a higher IP limit (e.g., 20 per hour).
  • Localization falls back to English. Check that the user’s locale is stored correctly (e.g., “pt-BR” vs “pt”). Also check that your translation file for that locale exists and is loaded. If the user has no locale stored, the fallback should be to the request header, then to the default.
  • Token hash comparison fails. Ensure you are hashing the token exactly as it appears in the URL. If the token contains characters that get URL-encoded (like “+” or “/”), decode the URL parameter before hashing.

Frequently Asked Questions

Should I use JWT for the reset token?

You can, but a simple random token stored hashed in the database is simpler and avoids JWT-specific issues (like revocation). JWTs are stateless, which means you cannot easily invalidate them before expiry. A database record gives you explicit single-use and revocation control.

How long should the reset link be valid?

30 minutes is a common secure default. It gives the user enough time to check email and click, but limits the attack window. Some services use 15 minutes for high-security apps. Never use 24 hours.

What if the user requests a reset twice?

The second request should invalidate the first token (delete the old record) and create a new one. This way, only the latest link works. This also prevents confusion if the user accidentally requested twice.

Should I lock the account after multiple failed reset attempts?

Locking the account can be a denial-of-service vector — an attacker can lock out a victim by triggering too many resets. It is safer to rate-limit the email sending, but not lock the account. If you must lock, do it only after a very high threshold (e.g., 10 failed attempts) and for a short time.

How do I handle the case where the user’s email is already in use?

That is not relevant to the reset flow. The reset flow is for existing users. If you are building a signup flow, you handle duplicate emails separately. For reset, always respond generically whether the email exists or not.

Can I send the reset link in the email body as plain text?

Yes, but it is better to use a button or a hyperlink with descriptive text. Some email clients block plain URLs. Use a real HTML link with the localized text “Reset my password”. Ensure the URL is absolute and includes the token.

What if the token is leaked in the browser history?

Using a POST form to submit the token (instead of a GET link) avoids it appearing in browser history. However, GET links are more user-friendly. A compromise is to use a GET link that immediately redirects to a POST form. The token will appear in the initial URL but not in the final page’s history.

Related

General steps for current mainstream versions; menus can differ by device and version. See the disclaimer or report an error.

More in this bed

The whole Tech How-To bed →