Passwords are still widely used to protect web applications, but they create several problems. Users often reuse passwords across websites, forget them, or choose passwords that are easy to guess. Even when an application stores passwords securely, attackers may steal them through phishing or data breaches.
Passkeys offer another way to authenticate users. Instead of entering a password, a user signs in with a credential created for a specific website or application. The credential uses public-key cryptography, and the private key is protected by the user's device or password manager.
For ASP.NET Core developers, adding passkey login means integrating the Web Authentication API, commonly called WebAuthn, with a server-side authentication flow. The browser communicates with an authenticator, while the server verifies the resulting cryptographic response.
In this article, we will look at how passkeys work, the components needed to implement them, and the security checks required for a production application.
What Is a Passkey?
A passkey is a passwordless authentication credential based on public-key cryptography. When a user registers a passkey, the authenticator creates a key pair.
The public key is sent to the application and stored on the server. The private key remains protected by the authenticator, which may be built into a phone, laptop, security key, or password manager.
During login, the server sends a challenge to the browser. The authenticator uses the private key to sign data associated with that challenge. The server verifies the signature using the stored public key.
The server does not need to store the user's private key or ask the user to enter a password.
How passkey authentication works
The process has two stages: registration and authentication.
During registration:
The user signs in using an existing trusted method.
The server creates registration options and a unique challenge.
The browser asks the authenticator to create a passkey.
The authenticator returns a public-key credential.
The server verifies the response and stores the public key and credential identifier.
During authentication:
The user selects an existing passkey.
The server creates a new authentication challenge.
The browser asks the authenticator to use the passkey.
The authenticator signs the challenge-related data.
The server verifies the signature and other required fields.
The application creates an authenticated session.
A passkey is not simply a replacement for a password input field. The application must implement the registration and verification protocols correctly.
What You Need Before Implementing Passkeys
An ASP.NET Core application needs several components to support passkey authentication.
Component | Responsibility |
|---|
ASP.NET Core backend | Creates challenges, verifies responses, and manages sessions |
Browser | Uses the Web Authentication API |
Authenticator | Creates and uses the private key |
Database | Stores credential IDs, public keys, and user associations |
HTTPS | Protects communication and is required for WebAuthn in normal production deployments |
Authentication library | Handles the WebAuthn protocol and cryptographic validation |
The WebAuthn API is available in supported browsers, but the exact user experience depends on the browser, operating system, authenticator, and account configuration.
For the server, use a maintained .NET WebAuthn implementation that supports the protocol features you require. Avoid writing the cryptographic verification logic from scratch.
The following examples illustrate the application flow. They are integration-oriented pseudocode rather than a complete, drop-in implementation for a specific WebAuthn library. Method names and request types will differ between libraries.
Step 1: Create a Table for Passkey Credentials
A user may register more than one passkey. For example, they might use a phone and a hardware security key.
Store each credential separately and associate it with the relevant user account.
A basic entity could look like this:
public class PasskeyCredential
{
public int Id { get; set; }
public string UserId { get; set; } = string.Empty;
public byte[] CredentialId { get; set; } = [];
public byte[] PublicKey { get; set; } = [];
public uint SignatureCounter { get; set; }
public string? DisplayName { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
The CredentialId identifies the credential, while PublicKey contains the public key used to verify authentication responses.
The signature counter may help detect some forms of credential cloning, depending on the authenticator. Not all authenticators maintain a useful counter, so an application must follow its library's guidance rather than treating every counter value as proof of cloning.
In a real application, the credential ID should have an appropriate unique database constraint. The application should also store any additional fields required by its WebAuthn library, such as credential type, supported transports, and backup-related information.
Protect this table from unauthorized modification. Replacing a stored public key or attaching a credential to the wrong account could undermine authentication security.
Step 2: Generate Registration Options
Passkey registration should normally require the user to authenticate through an existing trusted method first. Otherwise, an attacker might register a passkey on someone else's account.
After verifying the user's identity, the server generates registration options using its WebAuthn library.
Conceptually, the operation looks like this:
[Authorize]
[HttpPost("passkeys/register/options")]
public IActionResult CreateRegistrationOptions()
{
var userId = User.FindFirst(
System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
// Generate options through the configured WebAuthn library.
// Bind the operation to this authenticated user.
// Store the challenge in short-lived server-side state.
return Ok(new
{
Message = "Generate and return WebAuthn registration options here."
});
}
The example shows where registration begins, but the response must contain real WebAuthn registration options. A message or arbitrary JSON object cannot be used by the browser to register a passkey.
The WebAuthn library should generate a cryptographically random challenge and configure the expected relying party, user information, acceptable algorithms, and authenticator selection policy.
Store the challenge in short-lived state associated with the authenticated user and registration operation. Do not accept a challenge supplied independently by the client as the expected value.
Step 3: Register the Passkey in the Browser
The browser uses navigator.credentials.create() to ask the authenticator to create a credential.
A simplified browser-side example is shown below. The server-provided options must first be converted into the binary values required by the WebAuthn API.
const optionsResponse = await fetch(
"/api/passkeys/register/options",
{
method: "POST",
credentials: "same-origin"
}
);
if (!optionsResponse.ok) {
throw new Error("Unable to start passkey registration.");
}
// The response must contain real WebAuthn options.
// Convert challenge and user ID values from Base64URL
// into Uint8Array values before calling the browser API.
const publicKeyOptions = await getRegistrationOptions();
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
});
if (!credential) {
throw new Error("Passkey registration was cancelled.");
}
// Serialize the credential response into the format
// expected by the server-side WebAuthn library.
const registrationResponse = serializeCredential(credential);
const verificationResponse = await fetch(
"/api/passkeys/register/verify",
{
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(registrationResponse)
}
);
if (!verificationResponse.ok) {
throw new Error("Passkey registration failed.");
}
The functions getRegistrationOptions() and serializeCredential() represent application-specific conversion helpers. They must correctly handle the binary fields and response structures defined by WebAuthn.
The browser returns a credential containing registration data and an attestation response. The server must verify that response before storing the credential.
Do not trust a credential simply because the browser returned it successfully.
Step 4: Verify Registration on the Server
The registration verification endpoint receives the credential response and passes it to the WebAuthn library.
The library should verify the challenge, relying party ID, origin, response structure, and other protocol requirements. The application should also confirm that the credential is being registered for the currently authenticated user.
The main flow looks like this:
[Authorize]
[HttpPost("passkeys/register/verify")]
public async Task<IActionResult> VerifyRegistration(
RegistrationResponseDto response,
CancellationToken cancellationToken)
{
var userId = User.FindFirst(
System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
// Load the pending challenge for this user.
// Verify the response with the WebAuthn library.
// Confirm the expected origin and relying party ID.
// Reject expired or previously consumed challenges.
var verifiedCredential =
await VerifyWithWebAuthnLibraryAsync(
userId, response, cancellationToken);
if (verifiedCredential is null)
{
return BadRequest("Passkey registration could not be verified.");
}
// Save the verified credential ID and public key
// using the application's database context.
return Ok(new { Message = "Passkey registered successfully." });
}
RegistrationResponseDto and VerifyWithWebAuthnLibraryAsync() are placeholders for types and methods provided by your application and selected WebAuthn library. They are not built-in ASP.NET Core APIs.
The key rule is that the application stores the credential only after successful verification. Registration challenges should be short-lived and consumed after use so they cannot be replayed.
Step 5: Implement Passkey Login
Passkey login follows a similar process, but it uses navigator.credentials.get() instead of navigator.credentials.create().
The server creates authentication options containing a new challenge and the relying party ID. The browser passes those options to the authenticator, which signs the required data.
A simplified browser example is:
const optionsResponse = await fetch(
"/api/passkeys/login/options",
{
method: "POST",
credentials: "same-origin"
}
);
if (!optionsResponse.ok) {
throw new Error("Unable to start passkey login.");
}
// Decode the server's challenge and any allowed credential IDs.
const requestOptions = await getAuthenticationOptions();
const assertion = await navigator.credentials.get({
publicKey: requestOptions
});
if (!assertion) {
throw new Error("Passkey login was cancelled.");
}
const loginResponse = await fetch(
"/api/passkeys/login/verify",
{
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(serializeCredential(assertion))
}
);
if (!loginResponse.ok) {
throw new Error("Passkey authentication failed.");
}
As with registration, the conversion helpers must implement the required WebAuthn binary encoding and response serialization.
The server must verify the authentication response using the public key associated with the credential. It must check the challenge, origin, relying party ID hash, user-presence and user-verification requirements, and signature.
After successful verification, the application can establish an authenticated session using its configured ASP.NET Core authentication mechanism.
For cookie authentication, issue the authentication cookie only after verification succeeds. Configure the cookie with appropriate security settings, including HttpOnly, Secure, and a suitable SameSite policy.
Security Checks That Matter in Production
Validate the origin and relying party ID
WebAuthn binds credentials to a relying party and a web origin. Verify these values against server-side configuration rather than values supplied by the client.
A mismatch should cause verification to fail.
Protect the challenge
Generate a new unpredictable challenge for each operation. Bind it to the login or registration attempt, give it a short expiration, and prevent it from being reused.
Protect registration and recovery
Require an authenticated session and, where appropriate, recent reauthentication before adding a passkey. Account recovery must also be secure; otherwise, an attacker may bypass passkey protection through a weak recovery process.
Apply rate limits and monitoring
Limit repeated failed login attempts and record security-relevant events without logging private key material or sensitive authentication responses unnecessarily.
Handle multiple credentials
Allow users to view and remove registered passkeys through an authenticated account-management page. Require suitable verification before deleting credentials or changing authentication settings.
Common Mistakes to Avoid
Mistake | Why it is a problem | Correct approach |
|---|
Accepting registration responses without verification | Untrusted credentials could be stored | Verify using a maintained WebAuthn library |
Trusting the origin sent by the browser | A malicious origin could be accepted | Compare against server-side configuration |
Reusing challenges | Responses may become replayable | Generate and consume unique challenges |
Storing only a credential ID | The server also needs the public key and related metadata | Persist all required verified credential data |
Issuing a session before verification | Unauthenticated users may gain access | Create the session only after successful verification |
Ignoring account recovery | Attackers may bypass the primary authentication method | Secure recovery and credential management |
Advantages and Disadvantages of Passkeys
Advantages
Reduces dependence on passwords.
Provides strong protection against common phishing attacks when implemented correctly.
Uses public-key cryptography rather than sharing a password with the server.
Can make login easier through device-based authentication.
Supports authenticators and password managers across a range of devices.
Disadvantages
Requires additional registration and verification logic.
Browser, device, and authenticator behavior can vary.
Account recovery and device changes need careful planning.
Integrating WebAuthn directly requires handling binary data and protocol-specific details.
Existing applications may need changes to account management and authentication flows.
Summary
Passkeys provide a practical way to add passwordless authentication to an ASP.NET Core application. The browser communicates with an authenticator, while the server stores a public key and verifies signed authentication responses.
A secure implementation needs more than calling the browser's WebAuthn API. It must validate challenges, origins, relying party IDs, signatures, credential ownership, and session creation. It must also protect registration, recovery, and credential management.
For most applications, the safest approach is to use a maintained .NET WebAuthn library, follow its verification guidance, and test the complete registration and login flows across the browsers and authenticators your users rely on.
Join the conversation! Your thoughts help the community grow.