EnterpriseTotp 1.0.0
dotnet add package EnterpriseTotp --version 1.0.0
NuGet\Install-Package EnterpriseTotp -Version 1.0.0
<PackageReference Include="EnterpriseTotp" Version="1.0.0" />
<PackageVersion Include="EnterpriseTotp" Version="1.0.0" />
<PackageReference Include="EnterpriseTotp" />
paket add EnterpriseTotp --version 1.0.0
#r "nuget: EnterpriseTotp, 1.0.0"
#:package EnterpriseTotp@1.0.0
#addin nuget:?package=EnterpriseTotp&version=1.0.0
#tool nuget:?package=EnterpriseTotp&version=1.0.0
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:
- Validate outside any lock.
TwoFactor.Validatetouches no shared state and costs a few HMACs; there's no reason to hold a database lock across it. - Make the write itself the gate, as in the example above: a single conditional
UPDATEonVersion, 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 conditionalWHEREdoes, because whicheverUPDATEcommits second finds the row no longer matches and updates zero rows. - Treat any rowcount other than 1 as a failed login. This is the step that actually closes the race; without it the
UPDATEis decoration. AVersionmismatch means someone else moved the state under you, and you have no way to tell a legitimate second login from a replay. - Don't reach for
SERIALIZABLEtransactions,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 conditionalUPDATEalready 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
IsUsedis 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.Validatemakes 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→1mistyping. - What the library scrubs: hash buffers, HMAC output, and the salt‖code buffer used for recovery-code hashing are cleared with
CryptographicOperations.ZeroMemoryas soon as they're no longer needed, and the hot validation/hashing paths work onSpan<byte>/Span<char>rather than heap strings wherever possible. What it does not scrub:TotpEnrollmentResult.SecretBytes,SecretBase32,OtpAuthUri, andQrCodePngare display/persistence values handed back to you — .NET strings can't be scrubbed at all, so zeroSecretBytesandQrCodePngyourself once you're done with them, the same way the example above zeroessecretBytesafter 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 | Versions 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. |
-
net8.0
- QRCoder (>= 1.8.0)
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.