Themia.WebAuthn
0.57.2
See the version list below for details.
dotnet add package Themia.WebAuthn --version 0.57.2
NuGet\Install-Package Themia.WebAuthn -Version 0.57.2
<PackageReference Include="Themia.WebAuthn" Version="0.57.2" />
<PackageVersion Include="Themia.WebAuthn" Version="0.57.2" />
<PackageReference Include="Themia.WebAuthn" />
paket add Themia.WebAuthn --version 0.57.2
#r "nuget: Themia.WebAuthn, 0.57.2"
#:package Themia.WebAuthn@0.57.2
#addin nuget:?package=Themia.WebAuthn&version=0.57.2
#tool nuget:?package=Themia.WebAuthn&version=0.57.2
Themia.WebAuthn
WebAuthn/passkey ceremonies over Fido2NetLib (MIT), with the single-use challenge and the cloned-credential check an integration has to supply and usually does not.
Targets net8.0 and net10.0.
Not included, on purpose:
- Storing credentials. The public key, its signature counter and the user it belongs to live in
your users table, as the TOTP secret does for
Themia.Totp. This package holds only the in-flight ceremony. - Attestation via the FIDO metadata service. A synced passkey cannot be meaningfully attested — the private key is not pinned to hardware you can identify — and requesting it puts a network call in every registration.
- Account recovery, enrolment UI, and any policy about when a passkey is required. All of those bind to your users table.
The two things this adds
Fido2NetLib does the cryptography correctly. Both of the following are the relying party's job, both are easy to omit, and both fail with a successful sign-in — which is why they are here.
1. The challenge must be usable exactly once. The library verifies a response against the options it was issued with, but stores nothing. An integration that keeps those options anywhere reusable — a cache without eviction, a session that is not cleared, a row that is never deleted — accepts the same signed response twice.
2. The signature counter must move forward. An authenticator increments it on every assertion, so a value that does not advance means two authenticators are answering for one credential: the key has been extracted and copied (WebAuthn §7.2 step 21). The library reports the counter and takes no position on it. An integration that ignores it looks entirely healthy, because the cloned sign-in succeeds too.
Registration
builder.Services.AddThemiaWebAuthn<RedisWebAuthnChallengeStore>(o =>
{
o.ServerDomain = "example.com"; // registrable domain — no scheme, no port
o.ServerName = "Ezy Assets";
o.Origins = ["https://example.com"];
o.ChallengeTimeout = TimeSpan.FromMinutes(5);
o.RequireResidentKey = true; // what makes a credential a passkey
});
ServerDomain is a decision, not a setting: a credential is bound to it and cannot be used from
anywhere else, so changing it after users have registered invalidates every credential they hold.
Enrolling a passkey
// 1. Begin. userId must be opaque, stable and random — NOT an email or username. It is stored on
// the authenticator, it is exposed, and it cannot be changed later without invalidating the
// credential.
var options = await webAuthn.BeginRegistrationAsync(
user.WebAuthnHandle, // byte[], random, generated once per user
userName: user.Email, // shown in the authenticator's picker
displayName: user.FullName,
existingCredentialIds: await credentials.IdsForAsync(user.Id, ct), // so one authenticator does not enrol twice
ct);
// 2. Hand `options` to navigator.credentials.create() in the browser.
// 3. Verify what comes back.
var result = await webAuthn.CompleteRegistrationAsync(
response,
isCredentialIdUnique: (id, c) => credentials.IsUnusedAsync(id, c),
ct);
if (!result.Succeeded)
{
return Error(result.Outcome switch
{
WebAuthnOutcome.ChallengeNotFound => "That enrolment expired. Start again.",
_ => "That passkey could not be verified.",
});
}
// 4. Store it. SignCount is not optional — the clone check compares the next assertion against it.
await credentials.AddAsync(user.Id, result.CredentialId!, result.PublicKey!, result.SignCount, ct);
result.IsBackedUp tells you whether the authenticator syncs this credential to a provider. A
credential that is not backed up dies with the device — worth knowing before it is a user's only
one, and worth prompting a second enrolment for.
Signing in
// Empty list = passkey sign-in: the authenticator offers the account and the user never types an
// identifier. Pass specific ids only when you already know who is signing in.
var options = await webAuthn.BeginAuthenticationAsync([], ct);
// ...browser calls navigator.credentials.get(), posts the response back...
var stored = await credentials.FindAsync(response.RawId, ct) ?? return Reject();
var result = await webAuthn.CompleteAuthenticationAsync(response, stored, ct);
switch (result.Outcome)
{
case WebAuthnOutcome.Valid:
// PERSIST THE COUNTER. A caller that never updates it disables the clone check silently.
await credentials.UpdateSignCountAsync(stored.CredentialId, result.SignCount, ct);
return SignIn(stored.UserHandle);
case WebAuthnOutcome.SignCounterRegressed:
// Cryptographically valid and still not trustworthy: another authenticator is answering for
// this credential. Refuse, and treat it as a security event rather than a failed login.
await security.RaiseClonedCredentialAsync(stored.CredentialId, ct);
return Reject();
case WebAuthnOutcome.ChallengeNotFound: // expired, unknown, or a replay
case WebAuthnOutcome.VerificationFailed:
default:
return Reject();
}
The challenge store
public interface IWebAuthnChallengeStore
{
ValueTask StoreAsync(string challengeId, string optionsJson, TimeSpan ttl, CancellationToken ct = default);
ValueTask<string?> TryConsumeAsync(string challengeId, CancellationToken ct = default);
}
Required by the signature of AddThemiaWebAuthn<TChallengeStore> — there is no overload without it and
no default, for the same reason as Themia.Totp's replay store.
Retrieve and remove must be one operation. Split into a read followed by a delete, two concurrent submissions of the same response both read the options before either deletes them, and both are admitted — which is the replay the store exists to prevent.
// Redis: GETDEL is the whole operation
public async ValueTask<string?> TryConsumeAsync(string challengeId, CancellationToken ct)
=> await _redis.StringGetDeleteAsync($"webauthn:{challengeId}");
// SQL: DELETE ... RETURNING
// DELETE FROM webauthn_challenges WHERE id = @id AND expires_at > now() RETURNING options_json
On a store that does not expire on its own, the TTL must be a predicate in TryConsumeAsync — a
background sweep is not enough. Redis expires the key itself, so GETDEL cannot return a dead one.
SQL expires nothing: DELETE … WHERE id = @id RETURNING … hands back a challenge from last week, the
ttl argument silently becomes infinite, and the symptom is a successful sign-in against a stale
ceremony. That is why AND expires_at > now() is inside the statement above, and not only in whatever
job reclaims the rows. (Raised by ezy-assets on coord #0103, from a store of their own where the sweep
is written, tested, and not scheduled — and expiry is correct anyway, because the predicate is in the
consuming statement.)
It must also be shared across instances — a process-local store means a ceremony begun on one instance cannot be completed on another.
Passkeys or TOTP?
Both are in Themia and they are not alternatives to each other.
Themia.Totp |
Themia.WebAuthn |
|
|---|---|---|
| role | second factor beside a first one | replaces the first one |
| what the server stores | the same secret the phone holds | only the public key |
| your database leaks | working codes for every user | nothing usable |
| phishing | a fake site relays the code in real time | impossible — bound to the origin |
| user does | types 6 digits | one biometric touch |
| works without | anything: any authenticator app | a platform or browser that supports it |
A passkey is already two factors — the device (possession) plus the biometric or PIN that unlocks it (knowledge/inherence) — which is why it replaces the password rather than joining it.
If you are choosing one to build first, build passkeys. They remove the phishing surface that TOTP cannot, and they remove a step from the user's flow rather than adding one.
Enrolling a passkey buys nothing while a weaker login method stays enabled on the same account. An account's security is set by its weakest enabled method, not its strongest available one: leave SMS or mobile-OTP login switched on beside a passkey and the attacker simply takes that path. The deployment looks finished, the tests are green, and the phishing surface is exactly where it was.
So enrolment is not "add a passkey" — it is "add a passkey and demote what it replaces", either turning the weaker method off for that account or reducing it to a recovery path that cannot mint a session on its own. If your login model cannot currently express that distinction, that modelling is part of the work, not a follow-up. (Raised by ezy-assets on coord #0103, where mobile OTP is a full first-class login method rather than a second factor — the row above assumes the thing being replaced is a password, and it is not always.)
Keep TOTP for what passkeys cannot do: a user on a device with no platform authenticator, a shared or kiosk machine, step-up verification for a high-risk action, and as a recovery path when someone loses every device holding a passkey. A passkey deployment with no fallback locks people out, and TOTP is a reasonable fallback to have already built.
| 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 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. |
-
net10.0
- Fido2 (>= 4.0.1)
- Microsoft.Extensions.Configuration (>= 10.0.9)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Http (>= 10.0.9)
- Microsoft.Extensions.Logging (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options (>= 10.0.9)
- Microsoft.IdentityModel.JsonWebTokens (>= 8.19.1)
- Microsoft.IdentityModel.Logging (>= 8.19.1)
- Microsoft.IdentityModel.Tokens (>= 8.19.1)
-
net8.0
- Fido2 (>= 4.0.1)
- Microsoft.Extensions.Configuration (>= 10.0.9)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Http (>= 10.0.9)
- Microsoft.Extensions.Logging (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options (>= 10.0.9)
- Microsoft.IdentityModel.JsonWebTokens (>= 8.19.1)
- Microsoft.IdentityModel.Logging (>= 8.19.1)
- Microsoft.IdentityModel.Tokens (>= 8.19.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Themia.WebAuthn:
| Package | Downloads |
|---|---|
|
Themia.SecondFactor
Themia multi-method second-factor layer — credential store contract, method strength, per-app/per-role policy, recovery codes and federated amr helpers over Themia.WebAuthn, Themia.Totp and Themia.Challenges. No user table, transport or framework dependency. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.59.0 | 0 | 11/10/2026 |
| 0.58.0 | 37 | 11/9/2026 |
| 0.57.2 | 52 | 11/8/2026 |
| 0.57.1 | 51 | 11/7/2026 |
| 0.57.0 | 41 | 11/7/2026 |
| 0.56.0 | 49 | 11/6/2026 |
| 0.55.0 | 44 | 11/6/2026 |
| 0.54.0 | 48 | 11/5/2026 |
| 0.53.0 | 76 | 11/4/2026 |
| 0.52.0 | 92 | 11/3/2026 |
| 0.51.0 | 102 | 10/31/2026 |
| 0.50.0 | 94 | 10/31/2026 |
| 0.49.0 | 95 | 10/31/2026 |
| 0.48.0 | 94 | 10/30/2026 |
| 0.47.0 | 95 | 10/30/2026 |
| 0.46.0 | 86 | 10/30/2026 |
| 0.45.1 | 89 | 10/29/2026 |
| 0.45.0 | 97 | 10/29/2026 |
| 0.44.0 | 92 | 10/28/2026 |