This guide covers implementing and using passkey authentication (WebAuthn/FIDO2) in the BookStore application with .NET 10's built-in support.
Passkeys are a modern, passwordless authentication method that provides:
- Phishing-resistant security - Credentials are bound to specific domains
- No shared secrets - Only public keys are stored on the server
- Biometric/PIN authentication - Uses device authentication (Face ID, Touch ID, Windows Hello, etc.)
- Cross-device synchronization - Passkeys can sync across your devices via iCloud, Google Password Manager, etc.
- Better user experience - No passwords to remember or type
sequenceDiagram
participant User
participant Browser
participant API
participant Device
participant Database
Note over User,Database: Passkey-First Registration (Sign Up)
User->>Browser: Click "Register with Passkey"
Browser->>API: POST /account/attestation/options (with Email)
API->>API: Generate challenge + userId (User doesn't exist yet)
API->>Browser: Return {options, userId}
Browser->>Device: Request credential creation
Device->>User: Prompt for biometric/PIN
User->>Device: Authenticate (Face ID, etc.)
Device->>Browser: Create credential (private key stays on device)
Browser->>API: POST /account/attestation/result (with userId)
API->>Database: Create New User & Store public key
API->>Browser: Success + Auth Token
Note over User,Database: Login Flow
User->>Browser: Click "Sign in with Passkey"
Browser->>API: POST /account/assertion/options
API->>API: Generate challenge
API->>Browser: Return challenge
Browser->>Device: Request assertion
Device->>User: Prompt for biometric/PIN
User->>Device: Authenticate
Device->>Browser: Sign challenge with private key
Browser->>API: POST /account/assertion/result
API->>API: Verify signature with public key
API->>Browser: Issue JWT tokens
.NET 10 includes native passkey support in ASP.NET Core Identity, eliminating the need for external libraries like fido2-net-lib.
- Automatic credential storage - Passkeys stored with
ApplicationUserviaIUserPasskeyStore - Standard Identity endpoints - Integrated with
SignInManager - Configuration via options -
IdentityPasskeyOptions - Works with existing Identity - Integrates seamlessly with password authentication
Passkeys require binding to a specific domain (Origin) to prevent phishing.
appsettings.json (production-safe default):
{
"Authentication": {
"Passkey": {
"ServerDomain": "localhost",
"AllowedOrigins": [
"https://localhost:7260"
]
}
}
}appsettings.Development.json (optional local override):
{
"Authentication": {
"Passkey": {
"AllowedOrigins": [
"https://localhost:7260",
"http://localhost:7260"
]
}
}
}Key Settings:
- ServerDomain: Public domain of the API (e.g.
bookstore.comorlocalhost). - AllowedOrigins: List of origins allowed to perform passkey operations (e.g. your Web App URL).
- Production: HTTPS origins only.
- Development: localhost HTTP/HTTPS origins can be used for local testing.
Rate limiting is enforced on all passkey endpoints via the AuthPolicy.
- Partition:
tenantId:ip - Limit: configured via
RateLimit:AuthPermitLimitandRateLimit:AuthWindowSeconds- default: 20 requests / 60 seconds
- development: 200 requests / 60 seconds
- Violation: Returns
429 Too Many Requests.
Warning
Production Configuration
- In production, you MUST set
ServerDomainto your public domain (e.g.,bookstore.com) without protocol or port. - In production,
AllowedOriginsmust contain HTTPS-only origins. - Use environment variables:
Authentication__Passkey__ServerDomain=bookstore.comandAuthentication__Passkey__AllowedOrigins__0=https://bookstore.com.
The application exposes the following endpoints for Passkey operations:
-
POST
/account/attestation/options- Purpose: Generates WebAuthn creation options (challenge) for creating a new passkey.
- Request:
PasskeyCreationRequest { Email: string? } - Response:
{ options: {...}, userId: "guid" }- Returns both the WebAuthn options AND the generated user ID - Logic:
- If user is Authenticated: Generates options to add a passkey to the current account.
- If user is Anonymous (and Email provided): Generates options to register a new user with this passkey.
- Critical: The
userIdin the response MUST be sent back during attestation to ensure consistency.
-
POST
/account/attestation/result- Purpose: Completes the registration by verifying the attestation.
- Request:
RegisterPasskeyRequest { CredentialJson: string, Email: string?, UserId: string? } - Logic:
- Verifies the WebAuthn attestation.
- On attestation failure, returns a generic RFC7807
ProblemDetailsmessage (Attestation failed. Please try again.) while writing detailed diagnostics to server logs. - Derives the passkey device name from the request
User-Agentand stores a sanitized, HTML-encoded value to prevent unsafe metadata persistence. - Uses the
UserIdfrom the request (sent by client from options response) to create the user. - If Authenticated: Adds passkey to existing user.
- If Anonymous: Creates a new
ApplicationUserwith the provided Email and UserId.- If email verification is required, sends verification email and does not auto-login.
- If verification is not required, returns JWT access/refresh tokens.
- Critical: The
UserIdparameter ensures the passkey's embedded user ID matches the database user ID, enabling successful login.
-
POST
/account/assertion/options- Purpose: Generates WebAuthn assertion options (challenge) for login.
- Request:
PasskeyLoginOptionsRequest { Email: string? } - Logic: Supports both "Discoverable Credentials" (login without username) and username-based flows.
-
POST
/account/assertion/result- Purpose: Verifies the assertion and logs the user in.
- Request:
{ CredentialJson: string } - Logic:
- Verifies the WebAuthn signature.
- Retrieves the user via
IUserPasskeyStore.FindByPasskeyIdAsync. - Enforces tenant isolation by requiring the Marten session tenant to match the current request tenant context before accepting the passkey lookup result.
- Detects signature-counter mismatch scenarios (possible cloned authenticator) and locks the account when detected.
- On successful assertion, resets
AccessFailedCountso prior password failures do not cause an unexpected lockout after passkey sign-in. - Clears prior refresh tokens, then uses centralized
JwtTokenServiceto issue new access/refresh tokens. - Returns a standard
LoginResponse.
-
GET
/account/passkeys- Purpose: Lists the current user's registered passkeys.
- Security: Requires authentication and the
AuthPolicyrate limiting policy.
-
DELETE
/account/passkeys/{id}- Purpose: Removes a registered passkey by credential ID.
- Security: Requires authentication and the
AuthPolicyrate limiting policy.
We use a helper script wwwroot/js/passkeys.js to handle the navigator.credentials API calls.
window.passkey = {
register: async (optionsJson) => {
const options = JSON.parse(optionsJson);
// ... Convert base64 strings to Uint8Array ...
const credential = await navigator.credentials.create({ publicKey: options });
// ... Convert ArrayBuffers back to base64 ...
// IMPORTANT: Include userHandle in the response
return JSON.stringify(credentialResponse);
},
login: async (optionsJson) => {
// ... Similar flow for navigator.credentials.get() ...
},
isSupported: async () => {
return !!(window.PublicKeyCredential &&
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable());
}
};The PasskeyService acts as the bridge between the Blazor UI, the JavaScript interop, and the Backend API.
public class PasskeyService
{
// ...
public async Task<(string? Options, string? Error)> GetCreationOptionsAsync(string? email = null)
{
// Calls POST /account/attestation/options
// Returns JSON with { options: {...}, userId: "guid" }
}
public async Task<LoginResult?> RegisterPasskeyAsync(string credentialJson, string? email = null, string? userId = null)
{
// Calls POST /account/attestation/result
// Sends credentialJson, email, AND userId
// Returns LoginResult (Success + Tokens)
}
}The passkey's embedded user ID MUST match the database user ID. This is achieved by:
- Server generates a user ID in
/account/attestation/optionsand returns it with the options - Client extracts the
userIdfrom the response - Client sends the
userIdback in/account/attestation/result - Server uses this
userIdto create the database user
Example Client Code (Register.razor):
// 1. Get options (includes userId)
var (options, error) = await PasskeyService.GetCreationOptionsAsync(email);
// 2. Extract userId from response
string? userId = null;
string optionsJson = options;
using var doc = System.Text.Json.JsonDocument.Parse(options);
if (doc.RootElement.TryGetProperty("userId", out var userIdElem))
{
userId = userIdElem.GetString();
}
if (doc.RootElement.TryGetProperty("options", out var optionsElem))
{
optionsJson = optionsElem.GetRawText();
}
// 3. Create passkey with WebAuthn
var credentialJson = await JS.InvokeAsync<string>("passkey.register", optionsJson);
// 4. Send credential AND userId to server
var result = await PasskeyService.RegisterPasskeyAsync(credentialJson, email, userId);This ensures the passkey verification succeeds during login because the user IDs match.
- HTTPS Is Mandatory: WebAuthn refuses to run in insecure contexts. Production environments must use HTTPS.
- Domain Binding: Passkeys are chemically bound to the domain (e.g.,
bookstore.com). They cannot be phished byevil-bookstore.com. - User Verification: We force
UserVerification = Requiredto ensure the user actually performed a biometric check (FaceID/PIN), not just a presence check.
If you try to use "Passkey-First Registration" with an email that is already registered (e.g., with a password), the server will reject the request.
- Fix: Log in with your password, then go to "Manage Passkeys" to add a passkey to your existing account.
- Cause: You are likely accessing the site via
http://(not localhost) or your device has no biometric authenticator available. - Fix: Use
https://orlocalhost.