How to Implement Passkey Authentication in ASP.NET Core

By FoxLearn 2/11/2025 3:34:17 AM   105
In today’s digital world, securing user data while providing a seamless authentication experience is crucial. One of the most promising ways to achieve this is through Passkey authentication.

Leveraging the FIDO2 standards, Passkey authentication provides password-less, phishing-resistant security using public-key cryptography.

In this guide, we'll walk you through how to implement Passkey authentication in your ASP.NET Core application using the fido2-net-lib library, which simplifies the integration of FIDO2 authentication.

What is Passkey Authentication?

Passkey authentication is a modern, secure way to authenticate users without relying on traditional passwords. Instead, it utilizes public-key cryptography. This method involves a key pair where the public key is stored on the server, and the private key remains securely on the user’s device. When a user attempts to authenticate, the server sends a challenge that the user’s device signs with their private key, proving their identity without transmitting the private key itself.

Getting Started with fido2-net-lib

To integrate Passkey authentication, you’ll need to set up the fido2-net-lib library in your .NET project. Here’s how you can get started:

Step 1: Install fido2-net-lib

First, add the fido2-net-lib package to your project using either NuGet or the .NET CLI:

dotnet add package fido2-net-lib

Step 2: Set Up Your Project

Create a new ASP.NET Core app or use an existing one. Ensure your project structure is ready for handling API requests and responses.

Step 3: Configure the FIDO2 Service

The next step is to configure the FIDO2 service in your application by setting up your relying party information, such as your website’s name, domain, and origin.

using Fido2NetLib;

public void ConfigureServices(IServiceCollection services)
{
    services.AddFido2(options =>
    {
        options.ServerDomain = "example.com";
        options.ServerName = "Example";
        options.Origins = "https://example.com";
    });
}

Step 4: User Registration

For Passkey authentication, users need to register their devices. This involves generating attestation options, sending them to the user’s device, and verifying the response before storing the credentials.

What Are Attestation Options?

Attestation options are a set of parameters sent from the server to the user’s device during registration. These options include details about the relying party, the user, and the desired security settings. The user's device uses these options to generate a new key pair and returns an attestation object that the server can verify.

Generate Attestation Options

The following code shows how to generate the attestation options:

[Route("/AttestationOptions")]
public JsonResult Register([FromBody] string username)
{
    var user = DemoStorage.GetUser(username, () => new Fido2User
    {
        DisplayName = displayName,
        Name = username,
        Id = Encoding.UTF8.GetBytes(username)
    });

    var existingKeys = Storage.GetCredentialsByUser(username).ToList();

    var options = fido.RequestNewCredential(user, new List<PublicKeyCredentialDescriptor>(), AuthenticatorSelection.Default, AttestationConveyancePreference.None);

    HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson());

    return Json(options);
}

In this example:

  • User information: The Fido2User object contains essential details like the username and a unique user identifier.
  • Exclude list: The list of PublicKeyCredentialDescriptor objects ensures no duplicate devices are registered for the same user.
  • Authenticator selection: Defines the criteria for selecting authenticators, such as whether to allow cross-platform devices.
  • Attestation conveyance preference: Specifies how attestation data should be conveyed.

Register Credentials

To complete the registration, implement client-side logic that uses the attestation options to create a new credential.

const response = await fetch('/api/AttestationOptions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, username })
});

const options = await response.json();

const credential = await navigator.credentials.create({ publicKey: options });

const attestationResponse = {
    id: credential.id,
    rawId: Array.from(new Uint8Array(credential.rawId)),
    type: credential.type,
    response: {
        attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
        clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON))
    }
};

await fetch('/api/fido2/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(attestationResponse)
});

After the client sends the response, the server verifies and stores the credentials:

[Route("/register")]
public async Task<JsonResult> MakeCredential([FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken)
{
    var jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions");
    var options = CredentialCreateOptions.FromJson(jsonOptions);

    IsCredentialIdUniqueToUserAsyncDelegate callback = static async (args, cancellationToken) =>
    {
        var users = await Storage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken);
        return users.Count == 0;
    };

    var credential = await fido.MakeNewCredentialAsync(attestationResponse, options, callback, cancellationToken);

    Storage.AddCredential(options.User, new Credential
    {
        Id = credential.Id,
        PublicKey = credential.PublicKey,
        SignCount = credential.SignCount,
        RegDate = DateTimeOffset.UtcNow,
        Transports = credential.Transports,
        IsBackedUp = credential.IsBackedUp,
        userId = userId,
        DeviceName = "User Device 1"
    });

    return Json(credential.Status);
}

Step 5: Passkey Authentication

Once users have registered their devices, you’ll need to implement the authentication flow to verify their identity.

Generate Assertion Options

Generate the assertion options by fetching the user’s registered credentials:

[Route("/AssertionOptions")]
public JsonResult AssertionOptionsPost(string email)
{
    var user = DemoStorage.GetUser(username);

    var existingCredentials = Storage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList();

    var options = fido.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Preferred);

    HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson());

    return Json(options);
}

Verify the Response

Once the client receives the assertion options, the user’s device generates an assertion.

const response = await fetch('/api/AssertionOptions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username })
});

const options = await response.json();

options.challenge = base64urlToUint8Array(options.challenge);
options.allowCredentials = options.allowCredentials.map(cred => ({
    ...cred,
    id: base64urlToUint8Array(cred.id)
}));

let assertion;
try {
    assertion = await navigator.credentials.get({ publicKey: options });
} catch (err) {
    alert('Failed to get assertion: ' + err);
}

const assertionResponse = {
    id: assertion.id,
    rawId: arrayBufferToBase64Url(new Uint8Array(assertion.rawId)),
    type: assertion.type,
    response: {
        authenticatorData: arrayBufferToBase64Url(new Uint8Array(assertion.response.authenticatorData)),
        clientDataJSON: arrayBufferToBase64Url(new Uint8Array(assertion.response.clientDataJSON)),
        signature: arrayBufferToBase64Url(new Uint8Array(assertion.response.signature)),
        userHandle: assertion.response.userHandle ? arrayBufferToBase64Url(new Uint8Array(assertion.response.userHandle)) : null
    }
};

await fetch('/api/VerifyPasskey', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(assertionResponse)
});

Step 6: Verify Passkey

Finally, the server verifies the assertion and authenticates the user:

[Route("/VerifyPasskey")]
public async Task<JsonResult> MakeAssertion(VerifyPasskeyResponse clientResponse, CancellationToken cancellationToken)
{
    var jsonOptions = HttpContext.Session.GetString("fido2.assertionOptions");
    HttpContext.Session.Remove("fido2.assertionOptions");
    var options = AssertionOptions.FromJson(jsonOptions);

    var creds = Storage.GetCredentialById(clientResponse.Id);
    var storedCounter = creds.SignatureCounter;

    IsUserHandleOwnerOfCredentialIdAsync callback = async (args) =>
    {
        var storedCreds = await DemoStorage.GetCredentialsByUserHandleAsync(args.UserHandle);
        return storedCreds.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId));
    };

    var res = await fido.MakeAssertionAsync(clientResponse, options, creds.PublicKey, storedCounter, callback);

    DemoStorage.UpdateCounter(res.CredentialId, res.Counter);

    return Json(res);
}

The MakeAssertionAsync method ensures that the user’s identity is authenticated securely by verifying the assertion and updating the signature counter. If the verification is successful, the user is granted access.

Implementing Passkey authentication in your ASP.NET Core app enhances security by providing a password-less, phishing-resistant authentication method. By using the fido2-net-lib library, you can integrate FIDO2 authentication easily, making your application more secure while improving the user experience.