EnterpriseTotp 1.0.0

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

Enterprise TOTP

A .NET library for TOTP-based two-factor authentication: generate a secret and QR code for enrollment, validate submitted codes, and manage single-use recovery codes.

Designed by career security professionals for enterprise-grade use, and refined with intense security review. A few examples of the kind of care that went into it: comparisons of secret-derived values run in constant time, closing off timing-based attacks against those comparisons; a too-short or empty secret is handled correctly rather than silently validating codes an attacker could compute; and the API is shaped so you check the second factor before the password hash — closing off both a denial-of-service attack that forces a slow password hash (PBKDF2 or similar) to run on a flood of bogus passwords, and a password-guessing attack, since a guess is never confirmed correct unless the code is right too.

This library computes and validates codes. Your application is responsible for storing the secret codes securely. This can be used with an application that already manages its own SQL Server connection and encryption approach (SQL Server Always Encrypted, or another mechanism you already use for sensitive columns), and this library works with plain values that you persist however you choose.

Installation

dotnet add package EnterpriseTotp

Data structures to be persisted by your application


-- This table must only be updated by conditional UPDATE described below, never a blind write
CREATE TABLE UserTwoFactor (
    UserId               BIGINT         NOT NULL PRIMARY KEY,
    TotpSecret           VARBINARY(MAX) NOT NULL,  -- encrypted at rest by your own mechanism
    Version              BIGINT         NOT NULL DEFAULT 0,
    LastAcceptedAt       DATETIMEOFFSET NOT NULL DEFAULT '1970-01-01T00:00:00Z',
    NextAttemptAllowedAt DATETIMEOFFSET NOT NULL DEFAULT '1970-01-01T00:00:00Z'
);

-- Optional — only needed if you offer recovery codes.
CREATE TABLE UserTwoFactorRecoveryCodes (
    Id     INT            NOT NULL IDENTITY PRIMARY KEY,
    UserId BIGINT         NOT NULL REFERENCES UserTwoFactor(UserId),
    Salt   VARBINARY(32)  NOT NULL,
    Hash   VARBINARY(32)  NOT NULL,
    IsUsed BIT            NOT NULL DEFAULT 0
);

Enrolling a new user

using EnterpriseTotp.Enrollment;

var enrollment = TotpEnrollment.CreateSecret(issuer: "Acme Corp", accountLabel: user.Username);

// Persist (encrypted, your own mechanism), along with the starting 2FA state:
db.Database.ExecuteSql($"""
    INSERT INTO UserTwoFactor (UserId, TotpSecret)
    VALUES ({user.Id}, {YourEncrypt(enrollment.SecretBytes)})
    """);

// Display, do not persist — regenerate later from the stored secret if you need to show it again:
return View(new { qrPng = enrollment.QrCodePng, manualKey = enrollment.SecretBase32 });

The other columns have defaults that match TwoFactorState.ForNewEnrollment(), so a new row is ready to use.

Render qrPng inline as a data URI rather than writing it to a file or serving it from its own URL — the image is the shared secret, so it shouldn't get a path an attacker could guess, cache, or find left behind on disk:

<img src="data:image/png;base64,@Convert.ToBase64String(Model.qrPng)" alt="Scan with your authenticator app" />

(examples/LoginDemo is a console app with no inline-image option, so it draws the QR directly in the terminal instead — see its Program.cs.)

Validating a login

TwoFactor.Validate is the one call you need for the second factor. It handles rate limiting, TOTP validation, replay protection, and recovery codes, and hands back a TwoFactorState for you to store. Load the state, call it, write the state back, then check the password — that's the whole shape:

using EnterpriseTotp;
using System.Security.Cryptography;

// LoadTwoFactorState should read UserTwoFactor before *UserTwoFactorRecoveryCodes*, in one snapshot.
var state = LoadTwoFactorState(user.Id);   // your query; see the schema above


// Decrypt immediately before use, zero immediately after
byte[] secretBytes = YourDecrypt(user.EncryptedTotpSecret);
TwoFactorResult result = TwoFactor.Validate(state, secretBytes, submittedCode);
CryptographicOperations.ZeroMemory(secretBytes);

// One transaction for whatever the outcome, persisted unconditionally — even a Rejected outcome advances
// Version and the cooldown, and that write is what makes rate limiting and replay protection work. (A
// RateLimited outcome deliberately changes nothing, so its write is a harmless no-op.) Do this before
// checking IsGranted, not after. The Version check is the gate — see "Handling concurrent logins" below
// for why a plain SaveChanges() isn't enough on a load-balanced site.
using var transaction = db.Database.BeginTransaction();

int rowsUpdated = db.Database.ExecuteSql($"""
    UPDATE UserTwoFactor
       SET Version              = {result.NewState.Version},
           LastAcceptedAt       = {result.NewState.LastAcceptedAt},
           NextAttemptAllowedAt = {result.NewState.NextAttemptAllowedAt}
     WHERE UserId  = {user.Id}
       AND Version = {state.Version}
    """);

if (rowsUpdated != 1)
{
    // Someone else wrote this user's 2FA state between our read and our write — this may be a replay
    // attack, submitting the same code at the same moment as the legitimate user. Fail closed.
    return LoginResult.InvalidCredentials;
}

if (result.RedeemedRecoveryCodeId is not null)
{
    int recoveryRowsUpdated = db.Database.ExecuteSql($"""
        UPDATE UserTwoFactorRecoveryCodes SET IsUsed = 1 WHERE Id = {result.RedeemedRecoveryCodeId} AND IsUsed = 0
        """);

    if (recoveryRowsUpdated != 1)
    {
        // The code we just validated was revoked or already redeemed between our read and this write.
        // Rolling back here also discards this attempt's cooldown write from the UPDATE above — an
        // accepted, rare exception to "every rejected attempt sets the cooldown" (see "Rate limiting").
        return LoginResult.InvalidCredentials;
    }
}

transaction.Commit();

if (!result.IsGranted)
{
    return LoginResult.InvalidCredentials;
}

// The password check runs last, and only here — Validate already checked the code above, so a wrong
// code never reaches this deliberately slow KDF. The timing difference here is safe because the matched
// TOTP code was disabled by use.
return VerifyPassword(submittedPassword, user.PasswordHash, user.PasswordSalt)
    ? LoginResult.Success
    : LoginResult.InvalidCredentials;

Check the password only after result.IsGranted is true, never before — a wrong code should never reach a deliberately slow KDF. Validate itself doesn't enforce this ordering; getting it right is on you, the caller, same as the transaction above it.

This means a right code paired with a wrong password still persists as accepted: LastAcceptedAt advances and a redeemed recovery code is burned, exactly as if the password had also been correct. That's accepted — the only person who can trigger it is someone who already has that one specific, single-use code, and the cost to the legitimate user is at most a 30-second wait for TOTP, or one recovery code out of their set, not a compromise.

submittedCode can be a TOTP code or a recovery code; both are tried, so you don't need a separate "use a recovery code instead" path unless you want one in your UI.

result.Outcome distinguishes Granted, Rejected, and RateLimited. Log that distinction if it's useful to you, but send the same response to the client in the two failing cases.

Handling concurrent logins and preventing replay:

If an attacker obtains a code a legitimate user just entered — over their shoulder, from a compromised proxy, etc the code is still good for that window, and the attacker can submit it concurrently with the legitimate user's own request. Re-use of a code should be prevented by using something similar to the conditional UPDATE SQL shown above.

Some notes about preventing replay:

  1. Validate outside any lock. TwoFactor.Validate touches no shared state and costs a few HMACs; there's no reason to hold a database lock across it.
  2. Make the write itself the gate, as in the example above: a single conditional UPDATE on Version, whose rowcount you check. READ COMMITTED (SQL Server's default) does not prevent two connections from both reading the same value and both trying to write — the conditional WHERE does, because whichever UPDATE commits second finds the row no longer matches and updates zero rows.
  3. Treat any rowcount other than 1 as a failed login. This is the step that actually closes the race; without it the UPDATE is decoration. A Version mismatch means someone else moved the state under you, and you have no way to tell a legitimate second login from a replay.
  4. Don't reach for SERIALIZABLE transactions, SELECT ... WITH (UPDLOCK) held across validation, or a distributed lock (Redis, etc.). All three "work," but invite lock contention or a new dependency where a conditional UPDATE already suffices.

The Version check closes races on UserTwoFactor — two concurrent logins, or a login racing a rate-limit write. It does not cover UserTwoFactorRecoveryCodes: regenerating a user's codes only touches that table, so it never moves Version, and a login that read its state just before a regeneration commits can still validate a code that's already gone. That's why the second UPDATE's own rowcount is checked above: WHERE IsUsed = 0 is what catches this race.

Rate limiting

Repeated login attempts are rate-limited per account automatically — this defends against an attacker who already has the password (e.g. from phishing) and is now trying to guess the TOTP code itself. Attempts that reach the credential check are held to one per 10 seconds (TwoFactor.MinimumSecondsBetweenAttempts); a successful login clears the cooldown immediately.

There's nothing to configure and nothing extra to call. Storing NextAttemptAllowedAt as part of TwoFactorState is all that's required, as shown in the example code above.

TwoFactor.IsRateLimited(state) is available if you want to skip expensive work — password hashing, decrypting the secret — for an attempt that will be turned away anyway. It's an optimisation, not a requirement: Validate applies the same gate itself. Returning early on it is safe because a rate-limited attempt changes no state, so there is nothing you'd fail to persist.

The cooldown applies to a wrong TOTP or recovery code — not to a wrong password. The library never sees the password (see "Validating a login" above), so a right code paired with a wrong password clears the cooldown rather than setting it, the same as any other successful Validate call. If your password hash is fast and you want failed password checks to be rate-limited too, that's on you to add; this cooldown doesn't cover it.

One thing this does not do: it's per-account, so it won't slow an attacker trying one password or code against thousands of accounts — put a per-IP or global limit in front of your login endpoint for that.

The cooldown applies to every rejected attempt, with no exception for "no credential was even presented" — so anyone who knows a username can hold that account in a permanent cooldown, one request every ten seconds, forever, without ever knowing the password or a code. This is deliberate and fails closed, but it means the library cannot by itself stop a targeted denial-of-service against one user's login. A per-IP or per-source limit in front of your login endpoint is what closes this too.

A wider TotpOptions.Window weakens this protection, since more codes validate per guess — keep Window low if you're relying on rate limiting to slow down an attacker who already has the correct password.

Generating and redeeming recovery codes if the user gets a new phone

Regeneration is a privileged operation, not a login step: only run this block in a request that has already completed a full login (password and second factor), or behind whatever step-up re-authentication your application uses for changing credentials — the same bar as changing a password.

using EnterpriseTotp.RecoveryCodes;

// Once, at enrollment or on a "regenerate codes" request from an already-authenticated user:
var codes = RecoveryCodeGenerator.Generate();

using (var transaction = db.Database.BeginTransaction())
{
    db.Database.ExecuteSql($"DELETE FROM UserTwoFactorRecoveryCodes WHERE UserId = {user.Id}");
    foreach (var code in codes)
    {
        db.Database.ExecuteSql($"""
            INSERT INTO UserTwoFactorRecoveryCodes (UserId, Salt, Hash, IsUsed)
            VALUES ({user.Id}, {code.Salt}, {code.Hash}, 0)
            """);
    }
    transaction.Commit();
}

ShowToUserOnce(codes.Select(c => c.PlainTextCode)); // never stored anywhere as plaintext after this

Redemption needs no separate code path: submittedCode in the "Validating a login" example above is tried as both a TOTP code and a recovery code by the same TwoFactor.Validate call, which sets IsUsed on a matched recovery code as part of that same transaction. A user entering a recovery code logs in exactly the same way as one entering a TOTP code.

Configuring the algorithm and code shape

TotpOptions and RecoveryCodeOptions set the configurable options. The default values are known to work well for typical cases. Most of the time, you would have no reason to change any off these.

TotpOptions

Algorithm, Digits, and Step are baked into each user's app at enrollment — changing any of them in production breaks existing users' codes until they re-enroll. Window is the only one of the four that's safe to change live.

Option Range Default Why the bound exists
Algorithm Sha1, Sha256, Sha512 Sha1 Most authenticator apps assume SHA-1.
Digits 6–8 6 RFC 6238's range. Authenticator apps assume 6 and won't produce a longer code — interoperability, not strength.
Step seconds, ≥ 1 30 Must be positive; 0 would divide by zero when computing the current time step.
Window 0–10 1 Clock-drift tolerance — accepts codes this many steps before/after the current one. Higher is less secure: more codes validate at once.

RecoveryCodeOptions

Option Range Default Why the bound exists
CodeCount 0–50 10 0 is valid — some applications don't offer recovery codes at all.
CodeLength 8–64 10 Longer is more secure, but harder to type.

The TOTP secret and recovery codes are two independent credential stores. Revoking or resetting a user's 2FA must clear/rotate both — deleting the secret alone leaves any unused recovery codes still valid.

Security notes

  • Code and recovery-code comparisons use CryptographicOperations.FixedTimeEquals, not ==/.Equals(), to avoid leaking timing information about a partial match.
  • Secrets, recovery codes, and salts are generated with RandomNumberGenerator — never a non-cryptographic RNG.
  • Recovery-code IsUsed is an explicit flag your application persists and updates.
  • Check the password only after the second factor has already passed (see "Validating a login" above), never the other way around — a wrong code should never reach a deliberately slow password hash.
  • Secrets must be at least 16 bytes (Totp.MinimumSecretLengthBytes); a shorter secret throws, since it would otherwise silently produce attacker-computable codes. TwoFactor.Validate makes one exception: an empty secret rejects rather than throwing.
  • Submitted TOTP codes tolerate surrounding/embedded whitespace (a common artifact of copy-pasting from an authenticator app or SMS); submitted recovery codes additionally tolerate case, the display hyphen, and Crockford's O→0/I,L→1 mistyping.
  • What the library scrubs: hash buffers, HMAC output, and the salt‖code buffer used for recovery-code hashing are cleared with CryptographicOperations.ZeroMemory as soon as they're no longer needed, and the hot validation/hashing paths work on Span<byte>/Span<char> rather than heap strings wherever possible. What it does not scrub: TotpEnrollmentResult.SecretBytes, SecretBase32, OtpAuthUri, and QrCodePng are display/persistence values handed back to you — .NET strings can't be scrubbed at all, so zero SecretBytes and QrCodePng yourself once you're done with them, the same way the example above zeroes secretBytes after validation.

Compatibility

Targets .NET 8. No dependency on ASP.NET Core or any specific application framework — use it from a console app, Worker Service, WPF, WinForms, or ASP.NET Core.

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 was computed.  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 was computed.  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.0 116 8/3/2026

Initial release.