LowCodeHub.OTP 0.0.12

There is a newer version of this package available.
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
                    
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.0.12" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.OTP" Version="0.0.12" />
                    
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.0.12
                    
#r "nuget: LowCodeHub.OTP, 0.0.12"
                    
#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.0.12
                    
#: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.0.12
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.OTP&version=0.0.12
                    
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

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, Purpose and ConsumedAtUtc are set only on success, so a failed result never reveals who a reference id belongs to. On success, use 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 NotFound and left untouched.
  • SendAsync throws OtpSendThrottledException (an InvalidOperationException) 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. RetryAfterUtc 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 NotFound; a malformed caller-supplied OtpSendRequest.ReferenceId throws ArgumentException.
  • OtpResendResult contains 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 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 OtpSendThrottledException. 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 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
0.2.0 90 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 128 5/13/2026
0.0.3 119 5/12/2026
0.0.2 111 5/12/2026
0.0.1 123 5/12/2026