LowCodeHub.OTP 0.2.0

dotnet add package LowCodeHub.OTP --version 0.2.0
                    
NuGet\Install-Package LowCodeHub.OTP -Version 0.2.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="LowCodeHub.OTP" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.OTP" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.OTP" />
                    
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 LowCodeHub.OTP --version 0.2.0
                    
#r "nuget: LowCodeHub.OTP, 0.2.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 LowCodeHub.OTP@0.2.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=LowCodeHub.OTP&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.OTP&version=0.2.0
                    
Install as a Cake Tool

LowCodeHub.OTP

A provider-neutral OTP flow library for .NET. It gives applications a standard SendAsync, VerifyAsync, and ResendAsync workflow with reference ids, hashed OTP storage, expiry, resend cooldowns, max resend limits, max verification attempts, and replay prevention.

NuGet License: MIT

Feature LowCodeHub.OTP
Standard flow SendAsync, VerifyAsync, ResendAsync
Reference ids Every OTP challenge has an opaque ReferenceId
Secure storage Salted, peppered HMAC — never plaintext codes
Replay prevention Successful verification consumes the challenge
Resend rules Cooldown + maximum resend count
Brute-force guard Maximum failed verification attempts
Delivery Provider-neutral IOtpSender abstraction
Stores In-memory, Redis, SQL Server, PostgreSQL
Observability Health checks + LowCodeHub.OTP meter

Installation

dotnet add package LowCodeHub.OTP

Quick Start

using LowCodeHub.OTP.Extensions;

builder.Services
    .AddOtp(builder.Configuration)          // binds the "Otp" section
    .AddOtpRedis(builder.Configuration)     // binds "Otp:Redis"; omit to use the in-memory store
    .AddOtpSender<SmsOtpSender>();          // your delivery provider
{
  "Otp": {
    "HashPepper": "<at least 16 characters, from a secret store>",
    "Redis": { "ConnectionString": "localhost:6379" }
  }
}
public sealed class SmsOtpSender(ISmsClient sms) : IOtpSender
{
    public Task SendAsync(OtpDeliveryMessage message, CancellationToken cancellationToken = default)
        => sms.SendAsync(message.Recipient, $"Your code is {message.Code}", cancellationToken);
}

Every registration method also has a lambda form (AddOtp(o => ...), AddOtpRedis(o => ...)), and AddOtp(configuration, o => ...) binds first and then applies the lambda. All options are validated at startup.

Send, Verify, Resend

IOtpService returns Outcome<T> from LowCodeHub.Outcomes: either the success record or a ProblemDetails whose errorCode is one of OtpErrorCodes. Expected failures never throw; render them with ToHttpResult() (add AddProblemDetailsLocalization() if you want the messages localized by error code), or switch on the outcome yourself.

using LowCodeHub.Outcomes;
using LowCodeHub.OTP.Models;

app.MapPost("/otp/send", async (IOtpService otp, SendOtpBody body, CancellationToken ct) =>
{
    Outcome<OtpSendResult> outcome = await otp.SendAsync(new OtpSendRequest
    {
        Recipient = body.Phone,
        Channel = "sms",
        Purpose = "login"
    }, ct);

    // Throttled sends become 429 with an `errorCode` of Otp.SendThrottled and a `retryAfter` extension.
    return outcome.ToHttpResult(result => TypedResults.Ok(new { result.ReferenceId, result.ExpiresAtUtc, result.ResendAvailableAtUtc }));
});

app.MapPost("/otp/verify", async (IOtpService otp, OtpVerifyRequest request, CancellationToken ct) =>
{
    Outcome<OtpVerifyResult> outcome = await otp.VerifyAsync(request with { Purpose = "login" }, ct);

    // On success result.Recipient is the verified phone/email: sign that user in here.
    // Failures render as problem details; Otp.InvalidCode (400) includes `attemptsRemaining`.
    return outcome.ToHttpResult(result => TypedResults.NoContent());
});

app.MapPost("/otp/resend", async (IOtpService otp, OtpResendRequest request, CancellationToken ct) =>
    (await otp.ResendAsync(request, ct)).ToHttpResult());

To branch on a specific failure, switch on the outcome (OtpVerifyResult result => ..., ProblemDetails problem when problem.HasErrorCode(OtpErrorCodes.InvalidCode) => ...); the C# 15 union makes the switch exhaustive without a discard arm. The package targets net11.0.

Error codes

Code Status Extensions Meaning
Otp.NotFound 404 Unknown or malformed reference id, or issued for a different Purpose
Otp.Expired 410 The code expired
Otp.AlreadyConsumed 409 The code was already used
Otp.InvalidCode 400 attemptsRemaining (int) Wrong code
Otp.MaxAttemptsExceeded 429 No verification attempts left
Otp.SendThrottled 429 retryAfter (DateTimeOffset) Cooldown or resend budget blocks a new send
Otp.ResendTooSoon 429 retryAfter (DateTimeOffset) Resend requested inside the cooldown
Otp.MaxResendsExceeded 429 The challenge used all its resends
Otp.ConcurrencyConflict 409 Concurrent updates; gave up after retries (safe to retry)
  • Success records (OtpVerifyResult, OtpResendResult, OtpSendResult) hold only success data. A failure problem never reveals the recipient, channel or purpose, so it is safe to return as-is. On success, use OtpVerifyResult.Recipient to decide who is signed in; the reference id alone is not an identity.
  • Set OtpVerifyRequest.Purpose to the purpose your endpoint expects; a code issued for another purpose is reported as Otp.NotFound and left untouched.
  • SendAsync returns Otp.SendThrottled when it cannot send yet: the previous code was sent less than ResendCooldown ago, or the recipient used up MaxResendCount and must wait for the current code to expire. retryAfter says when to try again.
  • After the cooldown, SendAsync for the same recipient/channel/purpose supersedes the active code (the old reference id stops working) and counts as a resend, so repeated sends cannot bypass MaxResendCount. A consumed or expired code never blocks a new send.
  • Reference ids are opaque tokens of 1-128 characters [A-Za-z0-9_.-]. Malformed ids passed to VerifyAsync/ResendAsync are answered as Otp.NotFound; a malformed caller-supplied OtpSendRequest.ReferenceId throws ArgumentException.
  • Argument errors (ArgumentException), a missing IOtpSender, and sender or store failures still throw.

A background service deletes expired records once they are older than Otp:ExpiredRecordRetention (so callers still get Otp.Expired rather than Otp.NotFound shortly after expiry). It runs every Otp:CleanupInterval; set it to 00:00:00 to disable it. The Redis store uses native key TTLs (Otp:Redis:ExpiredRecordRetention) instead.

Metrics

Counters on the LowCodeHub.OTP meter (OtpMetrics.MeterName), tagged otp.channel, otp.purpose, otp.outcome:

Instrument otp.outcome values
lowcodehub.otp.sends sent, throttled, delivery_failed
lowcodehub.otp.verifications verified, invalid_code, expired, not_found, already_consumed, max_attempts_exceeded, concurrency_conflict
lowcodehub.otp.resends sent, delivery_failed, resend_too_soon, max_resends_exceeded, expired, not_found, already_consumed, concurrency_conflict
builder.Services.AddOpenTelemetry().WithMetrics(m => m.AddMeter(OtpMetrics.MeterName));

Recipients are never recorded. Failed verifications are tagged unknown for channel and purpose.

Security Notes

  • Codes are generated with a cryptographically secure RNG and are uniformly distributed.
  • Plaintext codes are only passed to IOtpSender; they are never stored. Never log OtpDeliveryMessage.Code.
  • Stored codes are HMAC-SHA256(key: HashPepper, salt || code) with a random 32-byte salt per code. Key stretching (PBKDF2/bcrypt) is deliberately not used: a 6-digit code has ~20 bits of entropy, so stretching cannot protect it once the pepper leaks, but it would let anyone burn server CPU through the unauthenticated verify endpoint. Protection comes from the secret pepper, the short expiry and the attempt limits.
  • Rotating HashPepper invalidates all outstanding codes (they fail as InvalidCode).
  • Verification uses constant-time comparison and hashes at most once per code, even across optimistic-concurrency retries.
  • A successful verification consumes the challenge and prevents replay.
  • Resend rotates the code, extends expiry and resets failed attempts, so the guess budget per recipient per expiry window is MaxVerifyAttempts × (MaxResendCount + 1) (20 by default). Tune both limits together.
  • Recipients are trimmed and lower-cased before storage and matching, so User@example.com and user@example.com are the same recipient on every store.
  • Every store enforces "one active code per recipient/channel/purpose" atomically (SQL filtered/partial unique index, Redis Lua script, in-memory slot map), so concurrent SendAsync calls for the same recipient produce exactly one code and the rest get Otp.SendThrottled. This only limits per-recipient abuse — still put a rate limiter (per IP / per account) in front of public OTP endpoints.
  • If IOtpSender throws during SendAsync, the new record is deleted so the recipient is not locked out. If it throws during ResendAsync, the previous code is restored (a lost race is logged as a warning).
Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
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
0.2.0 89 10/22/2026
0.0.12 85 10/22/2026
0.0.11 256 6/23/2026
0.0.10 129 6/21/2026
0.0.5 131 5/18/2026
0.0.4 127 5/13/2026
0.0.3 119 5/12/2026
0.0.2 111 5/12/2026
0.0.1 123 5/12/2026