LowCodeHub.OTP 0.0.10

There is a newer version of this package available.
See the version list below for details.
dotnet add package LowCodeHub.OTP --version 0.0.10
                    
NuGet\Install-Package LowCodeHub.OTP -Version 0.0.10
                    
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.10" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.OTP" Version="0.0.10" />
                    
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.10
                    
#r "nuget: LowCodeHub.OTP, 0.0.10"
                    
#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.10
                    
#: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.10
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.OTP&version=0.0.10
                    
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

Why This Library?

Feature LowCodeHub.OTP
Standard flow SendAsync, VerifyAsync, ResendAsync
Reference ids Every OTP challenge has a ReferenceId
Secure storage Stores salted hashes, 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

Installation

dotnet add package LowCodeHub.OTP

Quick Start

using LowCodeHub.OTP.Abstractions;
using LowCodeHub.OTP.Extensions;
using LowCodeHub.OTP.Models;

builder.Services
    .AddOtp(options =>
    {
        options.CodeLength = 6;
        options.Expiry = TimeSpan.FromMinutes(5);
        options.ResendCooldown = TimeSpan.FromSeconds(60);
        options.MaxResendCount = 3;
        options.MaxVerifyAttempts = 5;
        options.HashPepper = builder.Configuration["Otp:HashPepper"] ?? string.Empty;
    })
    .AddOtpRedis(redis =>
    {
        redis.ConnectionString = builder.Configuration.GetConnectionString("Redis")!;
    });

builder.Services.AddScoped<IOtpSender, SmsOtpSender>();
public sealed class SmsOtpSender : IOtpSender
{
    public Task SendAsync(OtpDeliveryMessage message, CancellationToken cancellationToken = default)
    {
        // Send message.Code through your SMS/email/WhatsApp provider.
        return Task.CompletedTask;
    }
}

Send, Verify, Resend

app.MapPost("/otp/send", async (IOtpService otp, CancellationToken ct) =>
{
    OtpSendResult result = await otp.SendAsync(new OtpSendRequest
    {
        Recipient = "+15551234567",
        Channel = "sms",
        Purpose = "login"
    }, ct);

    return TypedResults.Ok(result);
});

app.MapPost("/otp/verify", async (IOtpService otp, OtpVerifyRequest request, CancellationToken ct) =>
{
    OtpVerifyResult result = await otp.VerifyAsync(request, ct);
    return result.Succeeded ? TypedResults.NoContent() : TypedResults.BadRequest(result);
});

app.MapPost("/otp/resend", async (IOtpService otp, OtpResendRequest request, CancellationToken ct) =>
{
    OtpResendResult result = await otp.ResendAsync(request, ct);
    return result.Succeeded ? TypedResults.Ok(result) : TypedResults.BadRequest(result);
});

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"
  },
  "Otp:Redis": {
    "ConnectionString": "localhost:6379",
    "KeyPrefix": "otp:",
    "ExpiredRecordRetention": "01:00:00"
  }
}

Storage Providers

In-memory is registered by default and is useful for development and tests:

builder.Services.AddOtp();

This stores OTP records in process memory only. It does not create, migrate, or write to any database.

Redis:

builder.Services
    .AddOtp()
    .AddOtpRedis(options => options.ConnectionString = "localhost:6379");

This stores OTP records in Redis only. It does not insert records into SQL Server or PostgreSQL, and it does not require the SQL migration scripts.

SQL Server:

builder.Services
    .AddOtp()
    .AddOtpSqlServer(options =>
    {
        options.ConnectionString = builder.Configuration.GetConnectionString("SqlServer")!;
        options.Schema = "dbo";
        options.Table = "OtpRecords";
    });

PostgreSQL:

builder.Services
    .AddOtp()
    .AddOtpPostgreSql(options =>
    {
        options.ConnectionString = builder.Configuration.GetConnectionString("PostgreSql")!;
        options.Schema = "public";
        options.Table = "otp_records";
    });

SQL migration scripts are embedded under:

  • Repositories/SqlServer/Scripts
  • Repositories/PostgreSql/Scripts

Run the matching script in your database before using a SQL-backed store.

Initialize the Database

LowCodeHub.OTP does not create or migrate its own database schema during service registration. The schema scripts are idempotent and ship as embedded resources in the package, but the consuming application owns when and how they are applied.

You only need this section when using .AddOtpSqlServer(...) or .AddOtpPostgreSql(...). If you use the default in-memory store or .AddOtpRedis(...), no SQL table is used and no database migration is required.

Embedded resource prefixes:

Provider Embedded resource prefix
SQL Server LowCodeHub.OTP.Repositories.SqlServer.Scripts.
PostgreSQL LowCodeHub.OTP.Repositories.PostgreSql.Scripts.

The scripts create the OTP records table and indexes used by the SQL Server and PostgreSQL stores.

With LowCodeHub.Migration.SqlServer

Configure the migration package to scan the LowCodeHub.OTP assembly and include only the SQL Server OTP scripts:

using LowCodeHub.Migration.SqlServer.Extensions;
using LowCodeHub.OTP.Options;

builder.Services.AddMigration(o =>
{
    o.ConnectionString = builder.Configuration.GetConnectionString("Otp")!;
    o.Directories = ["LowCodeHub.OTP.Repositories.SqlServer.Scripts."];
});

await app.RunDatabaseMigrationAsync<SqlServerOtpOptions>();

With LowCodeHub.Migration.PostgreSql

using LowCodeHub.Migration.PostgreSql.Extensions;
using LowCodeHub.OTP.Options;

builder.Services.AddMigration(o =>
{
    o.ConnectionString = builder.Configuration.GetConnectionString("Otp")!;
    o.Directories = ["LowCodeHub.OTP.Repositories.PostgreSql.Scripts."];
});

await app.RunDatabaseMigrationAsync<PostgreSqlOtpOptions>();

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 will not discover these embedded scripts automatically. They are not EF model migrations and they will not appear in dotnet ef migrations list. EF users should create an application-owned EF migration and execute the embedded scripts from the LowCodeHub.OTP assembly in Up.

Example SQL Server migration:

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));
        }
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        // Drop OTP objects here if your application's migration policy requires reversible migrations.
    }
}

For PostgreSQL, loop over SqlOtp.PostgreSqlResources instead. SqlOtp.ReadFromResource(...) removes SQL Server GO separator lines so the SQL can be passed directly to EF Core's migrationBuilder.Sql(...).

Delivery Boundary

LowCodeHub.OTP does not depend on Twilio, SendGrid, SMTP, WhatsApp, or any other delivery provider. Applications own delivery by implementing IOtpSender.

This keeps the package generic, testable, and safe for different product requirements.

Mock Mode

For local development or automated tests, you can force a fixed code and use a no-op sender before a real IOtpSender exists:

builder.Services.AddOtp(options =>
{
    options.CodeLength = 4;
    options.MockCode = "1234";
    options.UseMockSender = true;
});

MockCode must be numeric and its length must match CodeLength. With the example above, VerifyAsync accepts 1234.

When MockCode or UseMockSender is set, a warning is logged at application startup so a leftover mock configuration cannot reach production silently.

Expired Record Cleanup

A background service periodically deletes expired OTP records once they have passed Otp:ExpiredRecordRetention (so callers still receive an Expired result instead of NotFound shortly after expiry). It runs every Otp:CleanupInterval (default 15 minutes); set the interval to 00:00:00 to disable it and manage cleanup yourself. The Redis store sets native key TTLs (Otp:Redis:ExpiredRecordRetention) instead, so its cleanup pass is a no-op.

Security Notes

  • Codes are generated with a cryptographically secure RNG and are uniformly distributed.
  • Plaintext OTP codes are only passed to IOtpSender; they are not stored.
  • Stored codes are salted and hashed (PBKDF2 with a server-side pepper).
  • Set Otp:HashPepper from a secret store in production. Rotating the pepper invalidates all outstanding codes — they fail verification as InvalidCode.
  • Verification uses constant-time hash comparison.
  • A successful verification consumes the challenge and prevents replay.
  • Resend rotates the OTP code, extends expiry, and resets failed verification attempts, so the total guess budget for one reference id is MaxVerifyAttempts × (MaxResendCount + 1) (20 with default settings). Tune both limits together.
  • Recipients are trimmed and lower-cased before storage and cooldown matching, so User@example.com and user@example.com are the same recipient on every store.
  • The resend cooldown is a best-effort guard, not a hard rate limit: the check is read-then-write, so two perfectly concurrent SendAsync calls for the same recipient can both pass it. Put a real rate limiter in front of public OTP endpoints.
  • If IOtpSender throws during SendAsync, the just-created record is deleted so the recipient is not locked out of requesting a new code by the cooldown.
  • Keep OTP messages short and never log OtpDeliveryMessage.Code.
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.0.11 153 6/23/2026
0.0.10 105 6/21/2026
0.0.5 115 5/18/2026
0.0.4 106 5/13/2026
0.0.3 101 5/12/2026
0.0.2 97 5/12/2026
0.0.1 105 5/12/2026