SableMail guide

← Back to blog

A Developer's Guide to Building Secure OTP Email Verification

Learn practical security principles for building email-based OTP verification, including secure generation, expiry, rate limiting, retries, storage, and user experience.

A six-digit code may look simple to users, but the system behind it needs careful security design. Email-based OTP verification affects account creation, passwordless login, account recovery, and sensitive user actions. If the flow is weak, attackers may abuse it; if it is confusing, genuine users may abandon it.

A secure OTP system is not just about generating a six-digit code. Developers need to think about how the code is generated, stored, delivered, expired, verified, retried, logged, and invalidated. The complete flow matters.

If you also need to verify how these emails behave end to end, our guide to QA testing email workflows covers trigger mapping, delivery timing, expiry checks, and inbox testing patterns.

Generate OTPs securely

Do not use predictable random functions for OTP generation. Standard random utilities may be fine for UI effects or non-security use cases, but OTPs are security-sensitive. Use cryptographically secure random generation provided by your backend language or framework. In Python, use the secrets module. In Node.js, use the crypto module.

Most email OTPs are six digits because they are easy for users to copy and type. For higher-risk actions, you may choose longer codes or combine OTP verification with additional checks. The goal is to make random guessing impractical within the allowed number of attempts and expiry window.

Store only what is necessary

Avoid storing OTPs in plain text when possible. A safer approach is to store a hashed version of the OTP along with the user identifier, expiry time, attempt count, and status. When the user submits the OTP, hash the submitted value and compare it with the stored hash.

The OTP record should contain enough information to validate the request, but not unnecessary personal data. Keep the storage simple and short-lived. OTP data should not remain in the database after it expires or after it has been used.

Use a short expiry window

OTPs should expire quickly. A common window is between 5 and 15 minutes depending on the use case. Short expiry reduces risk if an email account is shared, compromised, or opened later by mistake. For low-risk signup verification, a slightly longer window may be acceptable. For sensitive actions, shorter is better.

The user interface should clearly explain when the code expires. If a user enters an expired OTP, show a helpful message and provide an option to request a new code. Do not silently fail or show generic errors that make the user repeat the same mistake.

Make OTPs single-use

An OTP should become invalid immediately after successful verification. Even if the expiry time has not passed, the same code should not work again. This protects against replay and prevents confusion if users click back or retry the same action.

When a new OTP is generated, decide whether older active codes should remain valid. In most cases, the safest and clearest approach is to invalidate previous codes and allow only the newest code. This reduces confusion when users request multiple codes.

Rate limit both requests and attempts

OTP systems need two types of rate limits. First, rate limit how often a user can request a new OTP. Without this, attackers can spam a user's inbox or increase your email sending costs. Second, rate limit verification attempts so attackers cannot brute-force codes.

Rate limits can be based on email address, user account, IP address, device fingerprint, or a combination of signals. A common approach is to allow a small number of failed attempts, then temporarily block verification or require the user to request a fresh code.

Design safe resend behaviour

Users often click resend if an email is delayed. Your resend logic should be clear and safe. Show a short countdown before allowing another request. Let users know that the newest code replaces the old one. Avoid sending multiple active codes that all work at the same time.

It also helps to show the masked email address receiving the OTP, such as a***@example.com. This allows users to confirm they are checking the correct inbox without exposing the full address unnecessarily.

Avoid leaking information in errors

Error messages should be helpful but not reveal sensitive information. For login or account recovery flows, avoid messages that clearly confirm whether an email address exists in your system. Instead of saying 'This email is not registered', use safer wording such as 'If this email exists, we will send a code.'

For verification attempts, messages like 'Invalid or expired code' are usually safer than explaining exactly which part failed. Internally, logs can capture more detail for debugging, but the user-facing message should not help attackers refine their attempts.

Log carefully

Do not log OTP values in application logs, browser logs, analytics events, error tracking systems, or support dashboards. Logs are often accessible to more people and systems than production databases. If OTPs appear in logs, they become a security risk.

Instead, log safe metadata such as request time, success or failure, masked email, attempt count, and general error codes. This gives developers enough information to troubleshoot without exposing the secret itself.

Test with temporary inboxes safely

During development and QA, temporary inboxes are useful for checking whether OTP emails are delivered, formatted correctly, and easy to understand. Use synthetic accounts and non-sensitive data only. Public inboxes should never receive production user OTPs or private account information.

A strong OTP system balances security and usability. Generate codes securely, expire them quickly, invalidate them after use, rate limit aggressively, avoid leaking information, and test the complete user journey. When these pieces work together, email OTP verification becomes safer and easier for users to trust.

Need a temporary inbox for testing or signups?

Open SableMail