Blog Integrations & AI
SECURITY & PAYMENTS

Building a Reliable OTP Generator

Designing a one-time-password flow that holds up in production — token expiry, retry limits, and the small details that keep OTP both secure and painless for the user.

Dec 21, 2023

OTP generation looks trivial until you have to defend it against replay attacks, brute-force guessing, and the support ticket that reads "I never got my code." We've wired this flow into login, password-reset, and payment-confirmation paths across several .NET backends, and the six-digit number itself is the easy 10%. The rest is how you generate it, where you store it, how long you trust it, and how quickly you shut down someone trying to guess it.

Generating the Code

Don't reach for System.Random. It's a pseudo-random generator seeded in a way that's predictable enough to matter here, and an OTP is exactly the kind of value where predictability is the whole ballgame. Use RandomNumberGenerator from System.Security.Cryptography instead — it draws from the OS-level cryptographic source and costs nothing extra in a flow that already involves a database write and an SMS/email dispatch.

csharp
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

public static class OtpGenerator
{
    public static string Generate(int digits = 6)
    {
        Span<byte> buffer = stackalloc byte[4];
        RandomNumberGenerator.Fill(buffer);
        uint value = BitConverter.ToUInt32(buffer) % (uint)Math.Pow(10, digits);
        return value.ToString(new string('0', digits));
    }

    public static string Hash(string otp, string salt)
    {
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(salt));
        var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(otp));
        return Convert.ToBase64String(bytes);
    }
}

public class OtpService
{
    private readonly IOtpStore _store;

    public OtpService(IOtpStore store) => _store = store;

    public async Task<string> IssueAsync(string userId)
    {
        var code = OtpGenerator.Generate();
        var salt = Guid.NewGuid().ToString("N");

        var record = new OtpRecord
        {
            UserId = userId,
            HashedCode = OtpGenerator.Hash(code, salt),
            Salt = salt,
            ExpiresAtUtc = DateTime.UtcNow.AddMinutes(5),
            AttemptCount = 0
        };

        await _store.SaveAsync(record);
        return code; // hand this to the SMS/email sender, never persist it in plaintext
    }

    public async Task<bool> VerifyAsync(string userId, string submittedCode)
    {
        var record = await _store.GetAsync(userId);
        if (record == null || DateTime.UtcNow > record.ExpiresAtUtc)
            return false;

        if (record.AttemptCount >= 5)
            throw new InvalidOperationException("Too many attempts. Request a new code.");

        var attemptHash = OtpGenerator.Hash(submittedCode, record.Salt);
        var isValid = CryptographicOperations.FixedTimeEquals(
            Convert.FromBase64String(attemptHash),
            Convert.FromBase64String(record.HashedCode));

        if (!isValid)
        {
            record.AttemptCount++;
            await _store.SaveAsync(record);
            return false;
        }

        await _store.DeleteAsync(userId);
        return true;
    }
}

Storing and Expiring the Code

Never persist the OTP in plaintext, even in a cache entry with a five-minute TTL. Hash it the same way you'd hash a password, with a per-record salt, so a leaked table or a careless log statement doesn't hand out live codes. Key each record by user ID plus purpose — login, reset-password, payment-confirm — rather than just user ID, so a password reset in one tab doesn't collide with a login attempt in another. Five minutes is a sane default expiry; stretching it further trades real security for a support-ticket reduction that rarely shows up in practice.

  • Hash the OTP before it touches storage — treat it exactly like a password
  • Invalidate the previous code the instant a new one is issued for the same purpose
  • Store an absolute UTC expiry timestamp and check it server-side on every verification call
  • Cap resends to roughly one per 60 seconds so the SMS/email bill doesn't become the attack surface
  • Delete the record immediately on successful verification so a stolen code can't be replayed

Verification and Rate Limiting

Compare hashes with a constant-time comparison, CryptographicOperations.FixedTimeEquals in .NET, rather than == or SequenceEqual, so a timing side channel can't leak how many leading digits an attacker got right. Cap verification attempts per code at something like five, and once that's hit, force a fresh code rather than letting the client keep guessing against the same one. Layer a per-IP or per-account limiter on top of the per-code counter too — otherwise an attacker just rotates phone numbers or email addresses through your resend endpoint, which is a cost and abuse problem even when no single code ever gets cracked.

A resend should invalidate the old code and start a fresh attempt counter on the new one, not reset the counter on the existing code. We've seen brute-force protection quietly defeated because "resend" was implemented as "re-issue the same code and zero out the attempts."

Wrapping Up

An OTP flow is one of those features where the happy path takes an afternoon and the edge cases take the rest of the week. Get the randomness source, the hashed storage, and the attempt limiting right up front — retrofitting any of the three onto a live auth flow is considerably less pleasant.

OTP Security .NET

More from the blog

View all articles

Read something that fits your problem?

Tell us what you're building. We'll come back with an honest, scoped recommendation — not a sales pitch.

Start a Project