Implementing Passkeys to ASP.NET Core Application
By FoxLearn 2/11/2025 3:38:57 AM 126
Passkeys work through the FIDO2 protocol, which relies on public-key cryptography and allows for passwordless authentication.
Understanding Public-Private Key Cryptography
Before we dive into implementing passkeys, let’s take a moment to understand the basics of public-private key cryptography. Traditional cryptography often involves encrypting and decrypting data with a single key, like how we use a door key to lock and unlock a door. But on the web, we sometimes don’t want to share our “door key” but still need to prove that the door is ours and we hold the key.
This is where public-key cryptography comes in.
With public-key cryptography, a private key is generated first. A one-way function uses complex algorithms to create a public key from the private one. The public key is not secret, and it can be shared with anyone. What makes it special is that only the corresponding private key can generate that public key. This enables third parties to verify that the public key belongs to the correct user.
This same principle is applied to passkeys. The user’s device stores the private key, while the app retains the public key. This allows users to authenticate without exposing the private key, which is managed entirely by their device and never known to the user.
General Use of Passkeys
Despite all the advantages of passkeys, the web is still in the early stages of adoption. As of May 2024, many services still require a password even if passkeys are enabled. Therefore, it’s a good idea to allow users to create passkeys while still permitting them to log in with traditional passwords.
Additionally, passkeys are designed to eliminate the need for multi-factor authentication, so users can authenticate with a single step making it even easier.
Installing Necessary NuGet Packages
To get started with passkeys, you'll first need to install the following NuGet packages:
- Fido2
- Newtonsoft.Json
- UAParser (Optional, for device identity handling)
The following code libraries are based on Fido2's existing libraries, though they’ve been modified for this specific use case:
using Authentication_Playground_.Data; using Authentication_Playground_.Models; using Fido2NetLib; using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Fido2Identity { public class Fido2Storage { private readonly AppDbContext _dbContext; public Fido2Storage(AppDbContext dbContext) { _dbContext = dbContext; } public List<FidoStoredCredential> GetCredentialsByUsername(string username) { return _dbContext.FidoStoredCredentials.Where(c => c.Username == username).ToList(); } public async Task RemoveCredentialsByUsername(string username) { var item = await _dbContext.FidoStoredCredentials .Where(c => c.Username == username) .FirstOrDefaultAsync(); if (item != null) { _dbContext.FidoStoredCredentials.Remove(item); await _dbContext.SaveChangesAsync(); } } public async Task RemoveCredentialsById(int id) { var item = await _dbContext.FidoStoredCredentials .Where(c => c.ID == id) .FirstOrDefaultAsync(); if (item != null) { _dbContext.FidoStoredCredentials.Remove(item); await _dbContext.SaveChangesAsync(); } } public async Task<FidoStoredCredential> GetCredentialById(byte[] id) { var credentialIdString = Base64Url.Encode(id); credentialIdString = credentialIdString.Replace('-', '+').Replace('_', '/'); var cred = await _dbContext.FidoStoredCredentials .Where(c => c.DescriptorJson.Contains(credentialIdString)) .FirstOrDefaultAsync(); return cred; } public Task<List<FidoStoredCredential>> GetCredentialsByUserHandleAsync(byte[] userHandle) { return Task.FromResult(_dbContext.FidoStoredCredentials .Where(c => c.UserHandle.SequenceEqual(userHandle)) .ToList()); } public async Task UpdateCounter(byte[] credentialId, uint counter) { var credentialIdString = Base64Url.Encode(credentialId); credentialIdString = credentialIdString.Replace('-', '+').Replace('_', '/'); var cred = await _dbContext.FidoStoredCredentials .Where(c => c.DescriptorJson.Contains(credentialIdString)) .FirstOrDefaultAsync(); if (cred != null) { cred.SignatureCounter = counter; cred.LastLogin = DateTime.Now; await _dbContext.SaveChangesAsync(); } } public async Task AddCredentialToUser(Fido2User user, FidoStoredCredential credential) { credential.UserId = user.Id; _dbContext.FidoStoredCredentials.Add(credential); await _dbContext.SaveChangesAsync(); } public async Task<List<Fido2User>> GetUsersByCredentialIdAsync(byte[] credentialId) { var credentialIdString = Base64Url.Encode(credentialId); var cred = await _dbContext.FidoStoredCredentials .Where(c => c.DescriptorJson.Contains(credentialIdString)) .FirstOrDefaultAsync(); if (cred == null) return new List<Fido2User>(); return await _dbContext.Users .Where(u => Encoding.UTF8.GetBytes(u.UserHandle) .SequenceEqual(cred.UserId)) .Select(u => new Fido2User { DisplayName = u.Username, Name = u.Username, Id = Encoding.UTF8.GetBytes(u.UserHandle) }).ToListAsync(); } } }
Model
using Fido2NetLib.Objects; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.ComponentModel.DataAnnotations.Schema; namespace Authentication_Playground_.Models { public class FidoStoredCredential { public int ID { get; set; } public string Username { get; set; } public byte[] UserId { get; set; } public byte[] PublicKey { get; set; } public byte[] UserHandle { get; set; } public uint SignatureCounter { get; set; } public string CredType { get; set; } public DateTime RegDate { get; set; } public DateTime LastLogin { get; set; } public Guid AaGuid { get; set; } public string DeviceInfo { get; set; } [NotMapped] public PublicKeyCredentialDescriptor Descriptor { get { return string.IsNullOrWhiteSpace(DescriptorJson) ? null : JsonConvert.DeserializeObject<PublicKeyCredentialDescriptor>(DescriptorJson); } set { DescriptorJson = JsonConvert.SerializeObject(value); } } public string DescriptorJson { get; set; } } }
Implementation
Let's get started with the code implementation
1. Configure Middleware
First, you need to configure the middleware in Program.cs
. Add the following block before var app = builder.Build()
:
#region FIDO2 Configuration var fido2Configuration = new Fido2Configuration { ServerDomain = "foxlearn.com", // Replace with your server domain ServerName = "foxlearn", // Replace with your server name Origin = "https://foxlearn.com", // Replace with your origin URL TimestampDriftTolerance = 300000, // Tolerance in milliseconds for timestamp drift MDSCacheDirPath = null // Optional: Set if needed }; // Configure the Fido2Configuration service with the appropriate settings builder.Services.Configure<Fido2Configuration>(options => { options.ServerDomain = fido2Configuration.ServerDomain; options.ServerName = fido2Configuration.ServerName; options.Origin = fido2Configuration.Origin; options.TimestampDriftTolerance = fido2Configuration.TimestampDriftTolerance; options.MDSCacheDirPath = fido2Configuration.MDSCacheDirPath; }); // Register Fido2Configuration as a singleton service builder.Services.AddSingleton(fido2Configuration); #endregion
Note: Replace ServerDomain
and Origin
with your actual values. If working locally:
- Server Domain should be
localhost
. - Origin should be
https://localhost:{your-port}
.
2. Backend Preparation for Registration
Let's set up the backend for user registration.
#region Initializers and Variables private readonly AppDbContext _dbContext; private readonly Fido2 _lib; private readonly IOptions<Fido2Configuration> _optionsFido2Configuration; private readonly Fido2Storage _fido2Storage; public Fido2Controller(AppDbContext dbContext, IOptions<Fido2Configuration> optionsFido2Configuration) { _dbContext = dbContext; _optionsFido2Configuration = optionsFido2Configuration; // Initialize FIDO2 library with configuration _lib = new Fido2(new Fido2Configuration() { ServerDomain = _optionsFido2Configuration.Value.ServerDomain, ServerName = _optionsFido2Configuration.Value.ServerName, Origin = _optionsFido2Configuration.Value.Origin, TimestampDriftTolerance = _optionsFido2Configuration.Value.TimestampDriftTolerance }); _fido2Storage = new Fido2Storage(dbContext); } #endregion
3. Register Passkey (Registration Request)
This is the action that handles the user registration request:
#region REGISTER PASSKEY [HttpPost] public IActionResult RegisterRequest() { // Check if the user is authenticated if (HttpContext.Session.GetInt32("UserId").HasValue) { var user = _dbContext.Users .Where(s => s.Id == HttpContext.Session.GetInt32("UserId").Value) .FirstOrDefault(); if (user == null) return BadRequest(); // Generate user handle if not already set if (user.UserHandle == null) { byte[] userIdBytes = new byte[32]; new Random().NextBytes(userIdBytes); string userIdStr = Convert.ToBase64String(userIdBytes); user.UserHandle = userIdStr; _dbContext.SaveChanges(); } var userHandle = Convert.FromBase64String(user.UserHandle); var fidoUser = new Fido2User { DisplayName = user.Username, Name = user.Username, Id = userHandle }; // Get the user's existing keys (if any) var items = _fido2Storage.GetCredentialsByUsername(user.Username); var existingKeys = new List<PublicKeyCredentialDescriptor>(); foreach (var publicKeyCredentialDescriptor in items) { existingKeys.Add(publicKeyCredentialDescriptor.Descriptor); } // Create options for the new credential var authenticatorSelection = new AuthenticatorSelection { RequireResidentKey = false, UserVerification = UserVerificationRequirement.Required }; authenticatorSelection.AuthenticatorAttachment = AuthenticatorAttachment.Platform; var exts = new AuthenticationExtensionsClientInputs() { Extensions = true }; var options = _lib.RequestNewCredential(fidoUser, existingKeys, authenticatorSelection, AttestationConveyancePreference.Direct, exts); options.Rp = new PublicKeyCredentialRpEntity("localhost", "YcanInDev", null); List<PubKeyCredParam> pubKeyCredParams = new List<PubKeyCredParam> { new PubKeyCredParam(COSE.Algorithm.ES256, PublicKeyCredentialType.PublicKey), new PubKeyCredParam(COSE.Algorithm.RS256, PublicKeyCredentialType.PublicKey) }; options.PubKeyCredParams = pubKeyCredParams; // Store the options in session or cache HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson()); // Return the options to the client for the user to register the credential return Json(options); } else { Response.StatusCode = 401; return Json("Unauthorized"); } } #endregion
4. Register Passkey Response (Finalizing Registration)
This is the action that handles the final registration response after the user completes the registration on their device:
[HttpPost] public async Task<IActionResult> RegisterResponse([FromBody] AuthenticatorAttestationRawResponse attestationResponse) { try { if (HttpContext.Session.GetInt32("UserId").HasValue) { var jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions"); var options = CredentialCreateOptions.FromJson(jsonOptions); // Callback to ensure credential ID is unique to the user async Task<bool> callback(IsCredentialIdUniqueToUserParams args, CancellationToken token) { var users = await _fido2Storage.GetUsersByCredentialIdAsync(args.CredentialId); return users.Count == 0; }; // Verify and create the credentials var success = await _lib.MakeNewCredentialAsync(attestationResponse, options, callback); // Collect device info (optional) string deviceInfo = ""; try { var userAgent = HttpContext.Request.Headers["User-Agent"]; var uaParser = Parser.GetDefault(); ClientInfo c = uaParser.Parse(userAgent); deviceInfo = c.OS.Family.ToString(); } catch { } // Store the credentials in the database await _fido2Storage.AddCredentialToUser(options.User, new FidoStoredCredential { Username = options.User.Name, Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId), PublicKey = success.Result.PublicKey, UserHandle = success.Result.User.Id, SignatureCounter = success.Result.Counter, CredType = success.Result.CredType, RegDate = DateTime.Now, LastLogin = DateTime.Now, AaGuid = success.Result.Aaguid, DeviceInfo = deviceInfo }); // Return a success response return Ok(); } else { Response.StatusCode = 401; return Json("Unauthorized"); } } catch (Exception ex) { Response.StatusCode = 500; return Json("Unexpected error"); } } #endregion
The RegisterRequest
API sends configurations to the browser when the user requests them. The browser will prompt the native UI to confirm if the user wants to create a passkey. If successful, the browser sends the credential details back to the server via the RegisterResponse
endpoint.
For the front-end, we will implement HTML elements and JavaScript to allow users to register their passkeys. Upon clicking the "Create Passkey" button, the front-end fetches the required configurations from the server and triggers the browser's native UI. If the user successfully completes the process, the credential details are sent to the server, where they are stored.
HTML Part:
<div> <p id="message" class="instructions"></p> <mwc-button id="create-passkey" class="hidden btn btn-primary ms-3 mb-3" icon="fingerprint" raised> Create Passkey </mwc-button> </div>
JavaScript Part:
export const base64url = { encode: function (buffer) { const base64 = window.btoa(String.fromCharCode(...new Uint8Array(buffer))); return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); }, decode: function (base64url) { const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); const binStr = window.atob(base64); const bin = new Uint8Array(binStr.length); for (let i = 0; i < binStr.length; i++) { bin[i] = binStr.charCodeAt(i); } return bin.buffer; } }; export async function _fetch(path, payload = '') { const headers = { 'X-Requested-With': 'XMLHttpRequest', }; if (payload && !(payload instanceof FormData)) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(payload); } const res = await fetch(path, { method: 'POST', credentials: 'same-origin', headers: headers, body: payload, }); if (res.status === 200) { return res.json(); } else { const result = await res.json(); throw new Error(result.error); } }; const createPasskey = document.getElementById('create-passkey'); createPasskey.addEventListener('click', registerCredential); // Feature detections if (window.PublicKeyCredential && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable && PublicKeyCredential.isConditionalMediationAvailable) { try { const results = await Promise.all([ PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), PublicKeyCredential.isConditionalMediationAvailable() ]); if (results.every(r => r === true)) { createPasskey.classList.remove('hidden'); } else { document.getElementById('message').textContent = 'This device does not support passkeys'; } } catch (e) { console.error(e); } } else { document.getElementById('message').textContent = 'This device does not support passkeys'; } export async function registerCredential() { try { const options = await _fetch('/Fido2/RegisterRequest'); if (options.excludeCredentials) { for (let cred of options.excludeCredentials) { cred.id = base64url.decode(cred.id); } } options.authenticatorSelection = { authenticatorAttachment: 'platform', requireResidentKey: true }; options.challenge = base64url.decode(options.challenge); options.user.id = base64url.decode(options.user.id); let newCredential; try { newCredential = await navigator.credentials.create({ publicKey: options }); } catch (e) { alert("Could not create credentials in browser. The username may already be registered with your authenticator. Please change username or authenticator."); } try { registerNewCredential(newCredential); } catch (e) { alert("Could not create passkey"); } } catch (e) { if (e.name === 'InvalidStateError') { alert("This device already has a passkey for this service"); } else if (e.name === 'NotAllowedError') { return; } else { alert("Could not create passkey"); } } }; async function registerNewCredential(newCredential) { let attestationObject = new Uint8Array(newCredential.response.attestationObject); let clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); let rawId = new Uint8Array(newCredential.rawId); const data = { id: newCredential.id, rawId: coerceToBase64Url(rawId), type: newCredential.type, extensions: newCredential.getClientExtensionResults(), response: { AttestationObject: coerceToBase64Url(attestationObject), clientDataJson: coerceToBase64Url(clientDataJSON) } }; let response; try { let res = await registerCredentialWithServer(data); if (res.ok == true) { alert("Passkey saved successfully"); } else { alert("Could not create passkey"); } } catch (e) { alert("Could not create passkey"); } } async function registerCredentialWithServer(formData) { let response = await fetch('/Fido2/RegisterResponse', { method: 'POST', body: JSON.stringify(formData), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }); return response; } function coerceToBase64Url(input) { if (Array.isArray(input)) { input = Uint8Array.from(input); } if (input instanceof ArrayBuffer) { input = new Uint8Array(input); } if (input instanceof Uint8Array) { var str = ""; var len = input.byteLength; for (var i = 0; i < len; i++) { str += String.fromCharCode(input[i]); } input = window.btoa(str); } if (typeof input !== "string") { throw new Error("could not coerce to string"); } input = input.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); return input; } function base64ToArrayBuffer(base64) { var binaryString = atob(base64); var bytes = new Uint8Array(binaryString.length); for (var i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; }
Sign-In with Passkey
The sign-in flow follows a similar approach with two main endpoints: one for the sign-in request and another to handle the response from the browser’s OS. The front-end simply adds the autocomplete="webauthn"
attribute to the form to let the browser know that WebAuthn sign-in is supported.
<form asp-action="LoginUser" asp-controller="Account" method="post"> <div class="row mb-3"> <div class="col-md-12"> <label class="form-label">Username</label> <div class="d-flex align-items-center"> <input name="username" class="form-control flex-grow-1" maxlength="50" autocomplete="webauthn" required /> </div> </div> <div class="col-md-12"> <label class="form-label">Password</label> <div class="d-flex align-items-center"> <input type="password" name="password" class="form-control flex-grow-1" required /> </div> </div> </div> <div class="row"> <button type="submit" class="btn btn-success">Login</button> </div> </form>
The frontend code for the authentication is similar to the registration flow, with an additional authenticate()
function to interact with the SigninRequest
and VerifyWebAuthn
endpoints.
- Basic Authentication in ASP.NET Core
- How to Implement Stripe Payment Gateway in ASP.NET Core
- Comparing ASP.NET SOAP Services and Core APIs
- How to fix System.InvalidOperationException: Scheme already exists: Identity.Application
- Two-Factor Authentication with Google Authenticator in ASP.NET Core
- How to Implement Passkey Authentication in ASP.NET Core
- Implementing Google Authentication in the Blazor WebAssembly App
- How to Add a custom InputFormatter in ASP.NET Core