A C# .NET 9.0 Blazor library enabling simple passkey authentication.
$ dotnet add package ChatAIze.PasskeysC# .NET Blazor Server library for WebAuthn passkeys. It wraps the browser WebAuthn API and FIDO2 verification so you can focus on storage and your sign-in flow.
Passkey modelnet10.0. Your app must run on .NET 10 or you must retarget the library.PasskeyOptions.Domain) must match the effective domain of the origin.PasskeyProvider returns null/false on failure and suppresses exceptions by design.
Wrap calls and log state if you need diagnostics.OnAfterRenderAsync or UI event handlers, not during prerender.dotnet add package ChatAIze.Passkeys --version 0.2.8Register the provider in Program.cs:
using ChatAIze.Passkeys;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddPasskeyProvider(options =>
{
options.Domain = "example.com"; // rpId, no scheme
options.AppName = "Example"; // display name shown to users
options.Origins = new List<string>
{
"https://example.com"
};
});
var app = builder.Build();
app.UseStaticFiles(); // Required for the JS module in _content/ChatAIze.Passkeys
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();Optional: add @using ChatAIze.Passkeys in _Imports.razor to simplify component code.
The JS module is loaded via dynamic import, so you do not need to add script tags manually.
Domain must be a registrable domain (no scheme, path, or port). Example: example.com.Origins must include the exact origin (scheme + host + port) of your app.
Example: https://example.com or https://localhost:7238.For each credential, store:
Passkey.UserHandle)Passkey.CredentialId)Passkey.PublicKey)Optional but recommended:
A simple schema might look like:
UserIdUserHandle (bytes, <= 64 bytes recommended)CredentialId (bytes)PublicKey (bytes)SignCount (uint, optional)DisplayName (string, optional)Store multiple credentials per user to support multiple devices.
This flow is typically done after a user has authenticated with another method.
@inject PasskeyProvider PasskeyProvider
@code {
private async Task RegisterPasskeyAsync()
{
var userId = "user-123"; // stable, opaque identifier
var existingCredentialIds = await credentialStore.GetCredentialIdsAsync(userId);
var passkey = await PasskeyProvider.CreatePasskeyAsync(
userId,
userName: "user@example.com",
displayName: "Example User",
excludeCredentials: existingCredentialIds);
if (passkey is null)
{
// Registration failed or was cancelled
return;
}
await credentialStore.SaveAsync(new StoredCredential
{
UserId = userId,
UserHandle = passkey.UserHandle,
CredentialId = passkey.CredentialId,
PublicKey = passkey.PublicKey!
});
}
}Use excludeCredentials to prevent registering the same authenticator more than once.
This helps avoid duplicate credentials when the user retries registration.
Use a stable, opaque identifier (database ID, GUID, etc). Do not use email addresses or other user-visible data. Keep the byte length small (<= 64 bytes recommended).
If you expect discoverable credentials, you can let the browser choose:
@inject PasskeyProvider PasskeyProvider
@code {
private async Task SignInAsync()
{
var passkey = await PasskeyProvider.GetPasskeyAsync();
if (passkey is null)
{
return; // cancelled or failed
}
var user = await credentialStore.FindByCredentialIdAsync(passkey.CredentialId);
if (user is null)
{
return; // unknown credential
}
var ok = await PasskeyProvider.VerifyPasskeyAsync(
passkey,
user.UserHandle,
user.PublicKey);
if (!ok)
{
return; // verification failed
}
// Sign in user
}
}For security keys, provide allowCredentials:
var allowCredentials = await credentialStore.GetCredentialIdsAsync(userId);
var passkey = await PasskeyProvider.GetPasskeyAsync(allowCredentials: allowCredentials);
if (passkey is null)
{
return;
}
var ok = await PasskeyProvider.VerifyPasskeyAsync(
passkey,
storedUserHandle,
storedPublicKey);If the browser returns an empty user handle, resolve the user by CredentialId and
use that stored user handle for verification.
If you want to prefer discoverable passkeys but still allow security keys for a known user, use the fallback helper:
var allowCredentials = await credentialStore.GetCredentialIdsAsync(userId);
var passkey = await PasskeyProvider.GetPasskeyPreferDiscoverableAsync(
allowCredentials: allowCredentials);
if (passkey is null)
{
return;
}
var user = await credentialStore.FindByCredentialIdAsync(passkey.CredentialId);
if (user is null)
{
return;
}
var ok = await PasskeyProvider.VerifyPasskeyAsync(passkey, user.UserHandle, user.PublicKey);Conditional mediation surfaces passkeys in the browser autofill UI. In Blazor Server,
call it after the component renders and ensure an input with
autocomplete="username webauthn" exists.
<input @bind="_username" autocomplete="username webauthn" placeholder="Username" />protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
var available = await PasskeyProvider.IsConditionalMediationAvailableAsync();
if (!available)
{
return;
}
var passkey = await PasskeyProvider.GetPasskeyConditionalAsync();
if (passkey is null)
{
return; // user did not pick a credential
}
var user = await credentialStore.FindByCredentialIdAsync(passkey.CredentialId);
if (user is null)
{
return;
}
var ok = await PasskeyProvider.VerifyPasskeyAsync(passkey, user.UserHandle, user.PublicKey);
if (!ok)
{
return;
}
// Sign in user
}ValueTask<bool> ArePasskeysSupportedAsync(CancellationToken cancellationToken = default)Checks if the browser has the WebAuthn APIs. Returns false if JS interop is unavailable
or the app is not running in a browser.
ValueTask<bool> IsConditionalMediationAvailableAsync(CancellationToken cancellationToken = default)Checks if conditional mediation is available for passkey autofill.
ValueTask<Passkey?> CreatePasskeyAsync(
byte[] userId,
string userName,
string? displayName = null,
PasskeyOptions? options = null,
IReadOnlyCollection<byte[]>? excludeCredentials = null,
CancellationToken cancellationToken = default)Overloads accept string or Guid user IDs. userId should be a stable, opaque identifier.
The returned Passkey.PublicKey should be stored for future verification.
ValueTask<Passkey?> GetPasskeyAsync(
PasskeyOptions? options = null,
IReadOnlyCollection<byte[]>? allowCredentials = null,
CancellationToken cancellationToken = default)Use allowCredentials for security keys and other non-discoverable credentials.
ValueTask<Passkey?> GetPasskeyPreferDiscoverableAsync(
PasskeyOptions? options = null,
IReadOnlyCollection<byte[]>? allowCredentials = null,
CancellationToken cancellationToken = default)Tries a discoverable-credential request first, then retries with allowCredentials for
non-discoverable credentials if needed.
ValueTask<Passkey?> GetPasskeyConditionalAsync(
PasskeyOptions? options = null,
IReadOnlyCollection<byte[]>? allowCredentials = null,
CancellationToken cancellationToken = default)Starts conditional mediation (passkey autofill). Returns null if the user does not pick
a credential.
ValueTask<bool> VerifyPasskeyAsync(
Passkey passkey,
byte[] userId,
byte[] publicKey,
PasskeyOptions? options = null,
CancellationToken cancellationToken = default)Overloads accept string or Guid user IDs and base64 public keys. The public key
must be standard base64 (not base64url).
Passkey combines registration data (credential ID/public key) and assertion data
(authenticator response fields). Only UserHandle, CredentialId, and PublicKey
should be persisted; assertion fields are transient.
UserHandle (byte[])CredentialId (byte[])PublicKey (byte[]?)UserHandleBase64, CredentialIdBase64, PublicKeyBase64 (standard base64 helpers)AppName: relying party display name shown to usersDomain: rpId (registrable domain, no scheme)Origins: exact allowed origins (scheme + host + port)Passkey.*Base64 helpers use standard base64. If you store base64url, convert accordingly.allowCredentials or excludeCredentials, supply byte arrays. If you store
values as strings, decode them with Convert.FromBase64String.allowCredentials.excludeCredentials.PasskeyOptions.Domain and Origins.autocomplete="username webauthn".The repository includes a preview app in ChatAIze.Passkeys.Preview that demonstrates:
Run it locally and update PasskeyOptions to match your local URLs.
GPL-3.0-or-later