Pinqponq.Identity.Otp 1.0.1

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

Pinqponq.Identity.Otp

One-time code send/verify flow over email or SMS. Channel routing (mail/sms) lives inside this package; the storage interface for pending codes is left to the consuming application, matching the interface-only approach used by Pinqponq.Identity's refresh tokens.

Install

dotnet add package Pinqponq.Identity.Otp

Requirements

  • .NET 8.0, 9.0, or 10.0
  • An IOtpStore implementation from your application (Redis, EF Core, …) — required
  • Pinqponq.Sms (AddPinqponqSms) if you send codes over SMS
  • Pinqponq.Mail (AddPinqponqMail) if you send codes over email
  • A HashPepper secret of at least 32 characters

Quick start

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Pinqponq.Identity.Otp;
using Pinqponq.Identity.Otp.DependencyInjection;
using Pinqponq.Mail.DependencyInjection; // AddPinqponqMail

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPinqponqOtp(options =>
{
    options.CodeLength = 6;
    options.Ttl = TimeSpan.FromMinutes(3);
    options.MaxAttempts = 5;
    options.MinSendInterval = TimeSpan.FromSeconds(30);
    options.HashPepper = builder.Configuration["Otp:HashPepper"]!; // >= 32 chars
    options.EmailSubjectTemplate = "Your {0}-digit verification code";
    options.EmailBodyTemplate = "Your verification code is {0}. It expires in 3 minutes.";
});

// A sender is only required for the channel this application actually uses.
builder.Services.AddPinqponqMail(mail => builder.Configuration.GetSection("Smtp").Bind(mail));

// Application-owned persistence for pending OTP records (see "Main types" below).
builder.Services.AddScoped<IOtpStore, MyOtpStore>();

var app = builder.Build();

app.MapPost("/otp/send", async (
    SendOtpRequest request,
    IOtpService otpService,
    CancellationToken cancellationToken) =>
{
    // channel defaults to OtpChannel.Auto — email if the recipient contains '@', SMS otherwise.
    await otpService.GenerateAndSendAsync(
        recipient: request.Email,
        channel: OtpChannel.Email,
        purpose: "login",
        cancellationToken: cancellationToken);

    return Results.Accepted();
});

app.MapPost("/otp/verify", async (
    VerifyOtpRequest request,
    IOtpService otpService,
    CancellationToken cancellationToken) =>
{
    var status = await otpService.VerifyAsync(
        recipient: request.Email,
        code: request.Code,
        purpose: "login",
        cancellationToken: cancellationToken);

    return status switch
    {
        OtpVerifyStatus.Success => Results.Ok(),
        OtpVerifyStatus.Expired => Results.BadRequest("Code expired, request a new one."),
        OtpVerifyStatus.TooManyAttempts => Results.BadRequest("Too many attempts."),
        _ => Results.BadRequest("Invalid code."),
    };
});

app.Run();

Configuration

OtpOptions (configured via AddPinqponqOtp, validated on startup). Message templates use {0} as the code placeholder.

Option Default Notes
CodeLength 6 Must be between 4 and 12.
Ttl 180 seconds Must be positive. Code lifetime.
MaxAttempts 5 Must be greater than zero.
MinSendInterval 30 seconds Passed to IOtpSendRateLimiter. Must be >= 0. The default limiter is a no-op — see "Notes / behavior".
HashPepper Required. Must be at least 32 characters. Mixed into the code hash via HMAC-SHA256.
SmsTemplate "Your verification code: {0}" SMS body.
EmailSubjectTemplate "Your verification code: {0}" Email subject.
EmailBodyTemplate "Your verification code: {0}" Email body.

Main types

Type Lifetime Description
IOtpService Scoped GenerateAndSendAsync(recipient, channel, purpose, ct), VerifyAsync(recipient, code, purpose, ct). Requires the application to register IOtpStore.
IOtpStore App-provided SaveAsync, FindAsync, UpdateAsync, RemoveAsync, TryRemoveAsync, TryConsumeAsync.
IOtpSendRateLimiter Singleton (AllowAllOtpSendRateLimiter by default — no-op) TryAcquireAsync(key, minInterval, ct). Replace with a real limiter (e.g. Redis-backed) to enforce MinSendInterval.
OtpChannel Enum Auto (email if recipient contains @, else SMS), Sms, Email.
OtpVerifyStatus Enum Success, NotFound, Expired, TooManyAttempts, Mismatch.
OtpRecord Model Key, CodeHash, Recipient, ExpiresAt, Attempts, CreatedAt. The raw code is never persisted.
OtpSendRateLimitedException Exception Thrown by GenerateAndSendAsync when IOtpSendRateLimiter.TryAcquireAsync returns false.

IOtpStore.TryConsumeAsync is the core contract implementations must get right — it performs expiry check, attempt-limit check, hash comparison, remove-on-success and attempts-increment-on-mismatch atomically:

public sealed class MyOtpStore : IOtpStore
{
    private readonly ConcurrentDictionary<string, OtpRecord> _records = new();
    private readonly object _gate = new();

    public Task SaveAsync(OtpRecord record, CancellationToken ct = default)
    {
        _records[record.Key] = record;
        return Task.CompletedTask;
    }

    public Task<OtpRecord?> FindAsync(string key, CancellationToken ct = default) =>
        Task.FromResult(_records.GetValueOrDefault(key));

    public Task UpdateAsync(OtpRecord record, CancellationToken ct = default)
    {
        _records[record.Key] = record;
        return Task.CompletedTask;
    }

    public Task RemoveAsync(string key, CancellationToken ct = default)
    {
        _records.TryRemove(key, out _);
        return Task.CompletedTask;
    }

    public Task<bool> TryRemoveAsync(string key, string expectedCodeHash, CancellationToken ct = default)
    {
        lock (_gate)
        {
            if (_records.TryGetValue(key, out var record) && record.CodeHash == expectedCodeHash)
            {
                return Task.FromResult(_records.TryRemove(key, out _));
            }

            return Task.FromResult(false);
        }
    }

    public Task<OtpVerifyStatus> TryConsumeAsync(
        string key, string codeHash, int maxAttempts, DateTimeOffset utcNow, CancellationToken ct = default)
    {
        lock (_gate)
        {
            if (!_records.TryGetValue(key, out var record))
            {
                return Task.FromResult(OtpVerifyStatus.NotFound);
            }

            if (utcNow >= record.ExpiresAt)
            {
                _records.TryRemove(key, out _);
                return Task.FromResult(OtpVerifyStatus.Expired);
            }

            if (record.Attempts >= maxAttempts)
            {
                return Task.FromResult(OtpVerifyStatus.TooManyAttempts);
            }

            if (record.CodeHash != codeHash)
            {
                record.Attempts++;
                return Task.FromResult(OtpVerifyStatus.Mismatch);
            }

            _records.TryRemove(key, out _);
            return Task.FromResult(OtpVerifyStatus.Success);
        }
    }
}

Notes / behavior

  • Only the code's hash is ever persisted (OtpRecord.CodeHash, via HMAC-SHA256 keyed with HashPepper) — the raw code exists only in memory long enough to be sent.
  • Channel routing lives in this package. OtpChannel.Auto picks email when the recipient contains @, otherwise SMS. A sender is resolved lazily when a code is actually sent, so an application that only sends OTPs by email does not need to call AddPinqponqSms — the sender is registered via services.TryAddScoped<IOtpService>(...) with ISmsSender?/IEmailSender? as optional dependencies; whichever one is missing only throws when a send is routed to that channel, naming the registration to add (AddPinqponqSms / AddPinqponqMail).
  • IOtpStore is required at first resolve, not at registration. IOtpService is registered through a factory so ASP.NET Core's container validation in Development does not abort startup for an app that has not yet wired a store; the missing IOtpStore is reported as an InvalidOperationException the first time IOtpService is resolved.
  • Rate limiting is opt-in. The default IOtpSendRateLimiter is a no-op that always allows the send — register your own (Redis, in-memory, …) to actually enforce MinSendInterval and throw OtpSendRateLimitedException on abuse.
  • Delivery failure rolls back the code. If GenerateAndSendAsync fails to deliver, the record is removed via the compare-and-set TryRemoveAsync (matching on CodeHash) so a concurrent, newer GenerateAndSendAsync call for the same recipient/purpose is never accidentally deleted.
  • Target frameworks: net8.0, net9.0, net10.0.
  • Pinqponq.Sms — SMS sending (ISmsSender), needed when routing codes to the SMS channel.
  • Pinqponq.Mail — SMTP mail sending (IEmailSender), needed when routing codes to the email channel.
  • Pinqponq.Identity — JWT/refresh-token/ password primitives, typically issued right after a successful OTP verification.
  • Pinqponq.Auth.Totp — an alternative 2FA factor (RFC 6238 TOTP) that does not require sending anything.

Samples

Try this package in the browser via Pinqponq.Playground — see samples/README.md.

Repository

https://github.com/pinqponq/pinqnuqets

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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
1.0.1 192 8/7/2026
1.0.0 97 8/7/2026