LowCodeHub.OTP
0.2.0
dotnet add package LowCodeHub.OTP --version 0.2.0
NuGet\Install-Package LowCodeHub.OTP -Version 0.2.0
<PackageReference Include="LowCodeHub.OTP" Version="0.2.0" />
<PackageVersion Include="LowCodeHub.OTP" Version="0.2.0" />
<PackageReference Include="LowCodeHub.OTP" />
paket add LowCodeHub.OTP --version 0.2.0
#r "nuget: LowCodeHub.OTP, 0.2.0"
#:package LowCodeHub.OTP@0.2.0
#addin nuget:?package=LowCodeHub.OTP&version=0.2.0
#tool nuget:?package=LowCodeHub.OTP&version=0.2.0
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.
| 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, useOtpVerifyResult.Recipientto decide who is signed in; the reference id alone is not an identity. - Set
OtpVerifyRequest.Purposeto the purpose your endpoint expects; a code issued for another purpose is reported asOtp.NotFoundand left untouched. SendAsyncreturnsOtp.SendThrottledwhen it cannot send yet: the previous code was sent less thanResendCooldownago, or the recipient used upMaxResendCountand must wait for the current code to expire.retryAftersays when to try again.- After the cooldown,
SendAsyncfor the same recipient/channel/purpose supersedes the active code (the old reference id stops working) and counts as a resend, so repeated sends cannot bypassMaxResendCount. 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 toVerifyAsync/ResendAsyncare answered asOtp.NotFound; a malformed caller-suppliedOtpSendRequest.ReferenceIdthrowsArgumentException. - Argument errors (
ArgumentException), a missingIOtpSender, 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 logOtpDeliveryMessage.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
HashPepperinvalidates all outstanding codes (they fail asInvalidCode). - 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.comanduser@example.comare 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
SendAsynccalls for the same recipient produce exactly one code and the rest getOtp.SendThrottled. This only limits per-recipient abuse — still put a rate limiter (per IP / per account) in front of public OTP endpoints. - If
IOtpSenderthrows duringSendAsync, the new record is deleted so the recipient is not locked out. If it throws duringResendAsync, the previous code is restored (a lost race is logged as a warning).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- LowCodeHub.Outcomes (>= 0.0.1)
- Microsoft.Data.SqlClient (>= 7.1.1)
- Npgsql (>= 10.0.3)
- StackExchange.Redis (>= 3.4.5)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.