HedKam.OTPService 1.0.0

dotnet add package HedKam.OTPService --version 1.0.0
                    
NuGet\Install-Package HedKam.OTPService -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="HedKam.OTPService" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="HedKam.OTPService" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="HedKam.OTPService" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add HedKam.OTPService --version 1.0.0
                    
#r "nuget: HedKam.OTPService, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package HedKam.OTPService@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=HedKam.OTPService&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=HedKam.OTPService&version=1.0.0
                    
Install as a Cake Tool

HedKam.OTPService

A small, dependency-light one-time-password service for .NET. It issues numeric codes, keeps track of them, and verifies them — with cryptographic randomness, single-use redemption, a per-client guess limit, and per-client issuance throttling built in.

Targets .NET 10. The only dependency is Microsoft.Extensions.Options.

Install

dotnet add package HedKam.OTPService

Quick start

builder.Services.AddOTPService();
public class LoginController(IOTPService otpService)
{
    public IActionResult Send(string phoneNumber)
    {
        try
        {
            var otp = otpService.Generate(phoneNumber);

            // otp.Code    -> "4821"        send this to the user
            // otp.Message -> "4821"        or a formatted message, if you configured patterns

            return Ok();
        }
        catch (OTPGenerateLimitException)
        {
            return StatusCode(429);
        }
    }

    public IActionResult Verify(string phoneNumber, string code)
    {
        return otpService.Validate(code, phoneNumber) ? Ok() : Unauthorized();
    }
}

There is no handle to round-trip: you verify with the same client name you generated for. Nothing needs to be stored on the client between the two calls.

The default is one code per client per 60 seconds. A second Generate inside that window throws OTPGenerateLimitException, so handle it — it is the first thing you will hit when testing a resend button. Raise MaxGeneratePerWindow if your flow needs more.

AddOTPService registers the service as a singleton. Do not register OTPService yourself as scoped or transient — each instance owns its own store, so a code issued in one request would not be found in the next.

How it works

  • A client has at most one live code. Calling Generate again for the same client replaces the previous one, which stops working immediately.
  • A code is consumed on first successful verification. Verifying again fails with CodeIsUsed.
  • Wrong guesses are counted. After MaxAttempts failures the code is locked, even for the correct value. Failed guesses do not consume the code.
  • Issuing a new code resets that counter, which is exactly why MaxGeneratePerWindow exists — without it a caller could clear MaxAttempts at will. The two limits are only meaningful together: with the defaults, a client gets 5 guesses per minute.
  • Client names are trimmed, so "acme" and " acme " are the same client.

Configuration

builder.Services.AddOTPService(options =>
{
    options.DigitsCount = 6;
    options.ExpireInMinutes = 5;
    options.MaxAttempts = 3;
    options.MaxGeneratePerWindow = 3;
    options.MessagePatterns = [new OTPMessagePattern("sms", "Your code is {code}")];
});
Option Default Meaning
DigitsCount 4 Length of the generated code. Must be 1–10.
AllowDuplicateDigit true Whether a digit may repeat within one code.
AllowZero true Whether 0 may appear. With false the pool is 1–9.
ExpireInMinutes 10 How long a code stays valid. Must be greater than 0.
MaxAttempts 5 Failed guesses allowed against a code before it is locked.
MaxGeneratePerWindow 1 Codes one client may request per window.
GenerateWindowSeconds 60 Length of that issuance window.
CleanupIntervalSeconds 60 How often expired codes are swept. 0 sweeps on every Generate.
MessagePatterns empty Named templates; {code} is substituted into OTPResult.Message.
Errors see below The message strings returned or thrown on failure.

Options are validated when the host starts, so a bad configuration fails immediately with a named OptionsValidationException instead of silently disabling the service. Enforced rules:

  • DigitsCount between 1 and 10
  • ExpireInMinutes, MaxAttempts, MaxGeneratePerWindow, GenerateWindowSeconds all greater than 0
  • CleanupIntervalSeconds not negative
  • DigitsCount must fit the available digit pool when AllowDuplicateDigit is false — asking for 10 unique digits with AllowZero = false leaves only 9 to choose from, so it is rejected rather than looping

Verifying

Three methods run the same checks and differ only in how they report failure:

bool ok = otpService.Validate(code, clientName);

otpService.ValidateAndThrow(code, clientName);           // throws OTPValidationException

var result = otpService.ValidateAndReason(code, clientName);
// result.IsValid, result.ErrorMessage

Failure reasons come from OTPServiceOptions.Errors:

Property Raised when
CodeDoesNotExist No code is stored for that client — never issued, expired and swept, or the name does not match.
CodeIsInvalid The code does not match the client's current code.
CodeIsExpired The code is past ExpireInMinutes.
CodeIsUsed The code was already redeemed.
MaxAttemptsExceeded Too many failed guesses against this code.
GenerateLimitExceeded Carried by OTPGenerateLimitException from Generate.

Each defaults to its own name, so you can replace them with localized text:

builder.Services.AddOTPService(options =>
{
    options.Errors.CodeIsInvalid = "کد وارد شده صحیح نیست";
    options.Errors.CodeIsExpired = "کد منقضی شده است";
});

Message patterns

Generate takes an optional pattern name, and OTPResult.Message comes back rendered:

options.MessagePatterns = [new OTPMessagePattern("sms", "Your code is {code}")];

otpService.Generate(phoneNumber, "sms").Message;   // "Your code is 4821"
otpService.Generate(phoneNumber).Message;          // "4821"
otpService.Generate(phoneNumber, "unknown").Message; // "4821" — unknown names fall back to the raw code

Argument handling

clientName is yours, so it is validated strictly: null, empty, or whitespace throws ArgumentException.

code comes from an end user, so an empty or whitespace value is treated as a wrong code, not a programming error — it returns CodeIsInvalid and counts as an attempt. Only a null code throws.

Substituting dependencies

Register your own before AddOTPService and it will be used instead:

builder.Services.AddSingleton<ICodeGenerator, MyCodeGenerator>();   // e.g. alphanumeric codes
builder.Services.AddSingleton<TimeProvider>(fakeClock);             // e.g. testing expiry
builder.Services.AddOTPService();

Thread safety

IOTPService is safe to use concurrently, which is what makes the singleton registration correct. Redemption and attempt counting are atomic: a code racing across several threads is redeemed exactly once, and no failed guess goes uncounted.

Limitations

Codes are held in memory, in a single process. The library does not run across multiple servers and does not survive a restart — a code issued by one instance is unknown to every other. A shared backing store would be needed for that.

Generate must be rate limited upstream

The store is keyed by client name and has no global cap. MaxGeneratePerWindow limits each client, but nothing limits how many distinct client names a caller may invent, and every one of them retains an entry until it expires.

Measured cost is roughly 400 bytes per client, held for ExpireInMinutes. For an endpoint an attacker can reach with arbitrary identifiers:

Sustained rate Steady-state memory (10-minute expiry)
100 req/s ~24 MB
1,000 req/s ~240 MB
10,000 req/s ~2.4 GB

So put a real rate limit in front of Generate — per IP, per session, or per authenticated account — and prefer to call it only with identifiers you have already accepted, rather than whatever arrived in the request body. Lowering ExpireInMinutes cuts the exposure proportionally.

The library deliberately does not offer a MaxStoredCodes cap. Both obvious policies make things worse rather than better: evicting the oldest entries would let an attacker flush out legitimate users' codes on demand, and refusing new ones once full would let an attacker block all issuance far more cheaply than exhausting memory. A cap turns a resource problem into a targeted one.

Because a client holds only one live code, a user who requests a code on two devices can only finish on the most recent one.

Verification is by client name, so anyone who knows a user's identifier can burn that user's guesses and force them to wait for a new code. This is inherent to identity-based OTP; the expiry window and issuance limit bound it.

Cleanup of expired codes runs inside Generate. A service that issues nothing for a long stretch keeps expired entries until the next call.

Licence

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 146 8/31/2026