LowCodeHub.OTP
0.0.12
See the version list below for details.
dotnet add package LowCodeHub.OTP --version 0.0.12
NuGet\Install-Package LowCodeHub.OTP -Version 0.0.12
<PackageReference Include="LowCodeHub.OTP" Version="0.0.12" />
<PackageVersion Include="LowCodeHub.OTP" Version="0.0.12" />
<PackageReference Include="LowCodeHub.OTP" />
paket add LowCodeHub.OTP --version 0.0.12
#r "nuget: LowCodeHub.OTP, 0.0.12"
#:package LowCodeHub.OTP@0.0.12
#addin nuget:?package=LowCodeHub.OTP&version=0.0.12
#tool nuget:?package=LowCodeHub.OTP&version=0.0.12
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
app.MapPost("/otp/send", async (IOtpService otp, SendOtpBody body, CancellationToken ct) =>
{
try
{
OtpSendResult result = await otp.SendAsync(new OtpSendRequest
{
Recipient = body.Phone,
Channel = "sms",
Purpose = "login"
}, ct);
return Results.Ok(new { result.ReferenceId, result.ExpiresAtUtc, result.ResendAvailableAtUtc });
}
catch (OtpSendThrottledException ex)
{
return Results.Problem(
statusCode: StatusCodes.Status429TooManyRequests,
detail: ex.Message,
extensions: new Dictionary<string, object?> { ["retryAfterUtc"] = ex.RetryAfterUtc });
}
});
app.MapPost("/otp/verify", async (IOtpService otp, OtpVerifyRequest request, CancellationToken ct) =>
{
OtpVerifyResult result = await otp.VerifyAsync(request with { Purpose = "login" }, ct);
if (!result.Succeeded)
return Results.BadRequest(new { reason = result.FailureReason.ToString() });
// result.Recipient is the verified phone/email: sign that user in here.
return Results.NoContent();
});
app.MapPost("/otp/resend", async (IOtpService otp, OtpResendRequest request, CancellationToken ct) =>
{
OtpResendResult result = await otp.ResendAsync(request, ct);
return result.Succeeded ? Results.Ok(result) : Results.BadRequest(result);
});
OtpVerifyResult.Recipient,Channel,PurposeandConsumedAtUtcare set only on success, so a failed result never reveals who a reference id belongs to. On success, useRecipientto 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 asNotFoundand left untouched. SendAsyncthrowsOtpSendThrottledException(anInvalidOperationException) when it cannot send yet: the previous code was sent less thanResendCooldownago, or the recipient used upMaxResendCountand must wait for the current code to expire.RetryAfterUtcsays 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 asNotFound; a malformed caller-suppliedOtpSendRequest.ReferenceIdthrowsArgumentException. OtpResendResultcontains no recipient data and is safe to return as-is.
Configuration
{
"Otp": {
"CodeLength": 6,
"Expiry": "00:05:00",
"ResendCooldown": "00:01:00",
"MaxResendCount": 3,
"MaxVerifyAttempts": 5,
"ReferenceIdPrefix": "otp_",
"HashPepper": "load-this-from-a-secret-store",
"MockCode": null,
"UseMockSender": false,
"ExpiredRecordRetention": "01:00:00",
"CleanupInterval": "00:15:00",
"Redis": {
"ConnectionString": "localhost:6379",
"KeyPrefix": "otp:",
"ExpiredRecordRetention": "01:00:00"
},
"SqlServer": { "ConnectionString": "...", "Schema": "dbo", "Table": "OtpRecords" },
"PostgreSql": { "ConnectionString": "...", "Schema": "public", "Table": "otp_records" }
}
}
| Option | Default | Validation |
|---|---|---|
CodeLength |
6 | 4–10 |
Expiry |
5 min | > 0 |
ResendCooldown |
60 s | ≥ 0 |
MaxResendCount |
3 | ≥ 0 — shared by ResendAsync and superseding SendAsync |
MaxVerifyAttempts |
5 | > 0 |
ReferenceIdPrefix |
otp_ |
≤ 64 chars of [A-Za-z0-9_.-] |
HashPepper |
— | required, ≥ 16 characters |
MockCode |
null | digits only, length = CodeLength |
UseMockSender |
false | |
ExpiredRecordRetention |
1 h | ≥ 0 |
CleanupInterval |
15 min | ≥ 0 (0 disables cleanup) |
Store options are validated too: the SQL stores require ConnectionString and identifier-safe Schema/Table; Redis requires ConnectionString or ConnectionMultiplexer and a non-empty KeyPrefix.
Storage Providers
In-memory is registered by default and is meant for development and tests (single process only):
builder.Services.AddOtp(builder.Configuration);
Redis:
builder.Services
.AddOtp(builder.Configuration)
.AddOtpRedis(options => options.ConnectionString = "localhost:6379");
Redis needs no migration. Records live at {KeyPrefix}ref:{referenceId} and the one-active-code slot at {KeyPrefix}recipient:{sha256}; every write is a single Lua script, so it is atomic. For Redis Cluster use a hash-tagged prefix such as {otp}: so both keys share a slot. To reuse an existing connection, set RedisOtpOptions.ConnectionMultiplexer.
SQL Server:
builder.Services
.AddOtp(builder.Configuration)
.AddOtpSqlServer(options =>
options.ConnectionString = builder.Configuration.GetConnectionString("SqlServer")!);
PostgreSQL:
builder.Services
.AddOtp(builder.Configuration)
.AddOtpPostgreSql(options =>
options.ConnectionString = builder.Configuration.GetConnectionString("PostgreSql")!);
Each store registers a health check (otp-redis, otp-sqlserver, otp-postgresql, tagged otp and readiness). The SQL checks also fail when the configured table does not exist, so a missing migration or a wrong Table name is visible.
Initialize the Database
LowCodeHub.OTP does not create or migrate its own schema during service registration; the consuming application owns when the scripts run. This section only applies to .AddOtpSqlServer(...) and .AddOtpPostgreSql(...).
The embedded scripts are idempotent and create the table and indexes with the default names only ([dbo].[OtpRecords], "public"."otp_records"):
| Provider | Embedded resource prefix |
|---|---|
| SQL Server | LowCodeHub.OTP.Repositories.SqlServer.Scripts. |
| PostgreSQL | LowCodeHub.OTP.Repositories.PostgreSql.Scripts. |
Custom schema or table name
If you change Schema/Table, render the script for your names instead of using the embedded resources directly:
string sqlServer = SqlOtp.GetSqlServerSchemaScript(schema: "auth", table: "OtpCodes");
string postgres = SqlOtp.GetPostgreSqlSchemaScript(schema: "auth", table: "otp_codes");
Run it from your own migration (EF migrationBuilder.Sql(...), DbUp, a deployment script, ...). Index and key names are derived from the table name.
With LowCodeHub.Migration.SqlServer
using LowCodeHub.Migration.SqlServer.Extensions;
using LowCodeHub.OTP.Migrations;
builder.Services.AddSqlServerMigrations(migrations =>
{
migrations.AddTarget<IOtpScriptScanner>(o =>
{
o.ConnectionString = builder.Configuration.GetConnectionString("Otp")!;
o.Directories = ["LowCodeHub.OTP.Repositories.SqlServer.Scripts."];
});
});
await app.RunSqlServerMigrationAsync();
With LowCodeHub.Migration.PostgreSql
using LowCodeHub.Migration.PostgreSql.Extensions;
using LowCodeHub.OTP.Migrations;
builder.Services.AddPostgreSqlMigrations(migrations =>
{
migrations.AddTarget<IOtpScriptScanner>(o =>
{
o.ConnectionString = builder.Configuration.GetConnectionString("Otp")!;
o.Directories = ["LowCodeHub.OTP.Repositories.PostgreSql.Scripts."];
});
});
await app.RunPostgreSqlMigrationAsync();
Do not scan the whole LowCodeHub.OTP assembly without a directory filter, because the package contains scripts for both providers.
With EF Core Migrations
EF Core does not discover these scripts. Create an application-owned migration and execute them in Up:
using LowCodeHub.OTP.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
public partial class AddLowCodeHubOtpSchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
foreach (var resource in SqlOtp.SqlServerResources)
migrationBuilder.Sql(SqlOtp.ReadFromResource(resource));
// or, for custom names: migrationBuilder.Sql(SqlOtp.GetSqlServerSchemaScript("auth", "OtpCodes"));
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
For PostgreSQL use SqlOtp.PostgreSqlResources / SqlOtp.GetPostgreSqlSchemaScript(...). SqlOtp.ReadFromResource(...) strips SQL Server GO separator lines.
Delivery Boundary
LowCodeHub.OTP does not depend on Twilio, SendGrid, SMTP, WhatsApp, or any other delivery provider. Register your IOtpSender with AddOtpSender<T>() (scoped). Until one is registered, SendAsync fails with a clear "No IOtpSender is registered" error.
Mock Mode
For local development or automated tests, force a fixed code and use a no-op sender:
builder.Services.AddOtp(options =>
{
options.HashPepper = "development-only-pepper";
options.CodeLength = 4;
options.MockCode = "1234";
options.UseMockSender = true;
});
MockCode must be numeric and match CodeLength. When MockCode or UseMockSender is set, a warning is logged at startup so a leftover mock configuration cannot reach production silently.
Expired Record Cleanup
A background service deletes expired records once they are older than Otp:ExpiredRecordRetention (so callers still get Expired rather than 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 getOtpSendThrottledException. 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 | 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. |
-
net10.0
- Microsoft.Data.SqlClient (>= 7.1.1)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.13)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.13)
- Npgsql (>= 10.0.3)
- StackExchange.Redis (>= 3.4.5)
- System.Security.Cryptography.Pkcs (>= 10.0.13)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.