Introducing the next era of Duende IdentityServer.

Read our CEO’s announcement

Custom Passkey Attestation Policies: Restricting Login to Hardware Keys

Maarten Balliauw
Two blue circles

A passkey on a YubiKey stays in tamper-resistant hardware. The private key never leaves the device. But a passkey synced through iCloud Keychain or Google Password Manager? That key material lives on every device linked to the account, backed up to a cloud service you don't control.

For regulated environments like finance, healthcare, or government, that difference matters because internal requirements or government regulations may add specific rules to what hardware keys your users are allowed to use.. Duende User Management lets you enforce the permitted key types at registration time.

Summary
• Synced passkeys (iCloud Keychain, Google Password Manager) store key material in the cloud; hardware keys keep it on-device.
• Duende User Management's IAttestationTrustPolicy interface lets you reject passkey registrations at the server before credentials are stored.
• Set AttestationConveyancePreference to "direct" so attestation data actually reaches your server.
• Build an AAGUID allowlist of approved hardware authenticator models and reject everything else.
• Move the allowlist to appsettings.json or another configuration store so your security team can update it without code deploys.

Platform Authenticators May Not Be Enough

As covered in our Introduction to Passkeys series, passkey authentication falls into two categories:

Platform authenticators (Windows Hello, iCloud Keychain, Google Password Manager) store credentials on the device or sync them across an ecosystem. Password managers like 1Password and Bitwarden do the same: they generate and sync passkeys across your devices, which is great for convenience but means your private key exists in multiple places. The risks:

  • Key material gets backed up to cloud services outside your control
  • A compromised cloud account (or password manager vault) exposes every synced passkey
  • No guarantee the authenticator is hardware-backed

Roaming authenticators (YubiKey, Google Titan, Feitian keys) are hardware tokens. The private key never leaves the secure element. You get:

  • Non-exportable keys
  • Physical presence required
  • Attestation certificates proving the authenticator's identity

If your organization's security policy requires credentials to live in hardware, you need to check attestation at registration and reject what doesn't qualify.

How Attestation and AAGUIDs Work

When a user registers a passkey, the browser calls navigator.credentials.create() and the authenticator generates a new key pair. The response that comes back to your server includes an attestation statement, which is the authenticator's way of proving what it is.

Tip: We covered the full registration ceremony in our deep dive into passkeys with Duende IdentityServer.

Buried in that attestation response is the AAGUID (Authenticator Attestation GUID): a 128-bit identifier the manufacturer assigns to each authenticator model. Think of it as a product SKU for security keys. A YubiKey 5 NFC has a different AAGUID than a YubiKey 5C, which has a different one from a Google Titan.

Some well-known hardware AAGUIDs:

Authenticator AAGUID

YubiKey 5 NFC

2fc0579f-8113-47ea-b116-bb5a8db9202a

YubiKey 5C

c1f9a0bc-1dd2-404a-b27f-8e29047a43fd

Google Titan (USB-A)

42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3

The FIDO Alliance publishes AAGUIDs through their Metadata Service (MDS). There's also a handy community-maintained AAGUID list with a visual explorer that covers both hardware keys and software providers like 1Password and Bitwarden.

By building an allowlist of AAGUIDs you trust, you can accept registrations from approved hardware keys and turn away everything else. One prerequisite: you need to set AttestationConveyancePreference to "direct" in your passkey configuration. Without that, browsers will strip the attestation data from the response before it reaches your server, and you'll have nothing to check.

Implementing IAttestationTrustPolicy

If you haven't set up User Management yet, the getting started guide walks you through it.

Duende User Management gives you a hook into the passkey registration flow through the IAttestationTrustPolicy interface. Your implementation gets called after the browser sends the attestation response but before the credential is stored in the database. This is where you decide: does this authenticator meet our requirements?

The AttestationTrustContext passed to your policy contains the AAGUID, the attestation format (like "packed", "tpm", or "none"), and the certificate chain if one was provided. For a hardware-only policy, the two things you care about are whether attestation was actually provided (the format isn't "none") and whether the AAGUID is on your approved list.

C#

using Duende.UserManagement.Authentication.Passkeys;

public class HardwareKeyOnlyPolicy : IAttestationTrustPolicy
{
    private static readonly HashSet<Guid> AllowedAaguids = new()
    {
        // YubiKey 5 Series
        Guid.Parse("2fc0579f-8113-47ea-b116-bb5a8db9202a"), // YubiKey 5 NFC
        Guid.Parse("c1f9a0bc-1dd2-404a-b27f-8e29047a43fd"), // YubiKey 5C
        Guid.Parse("cb69481e-8ff7-4039-93ec-0a2729a154a8"), // YubiKey 5Ci
        Guid.Parse("fa2b99dc-9e39-4257-8f92-4a30d23c4118"), // YubiKey 5 NFC FIPS
        
        // Google Titan
        Guid.Parse("42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3"), // Titan USB-A
        Guid.Parse("b93fd961-f2e6-462f-b122-82002247de78"), // Titan USB-C
    };

    public ValueTask<AttestationTrustPolicyResult> EvaluateAsync(
        AttestationTrustContext context,
        CancellationToken ct)
    {
        // No attestation means the authenticator didn't identify itself
        if (context.AttestationFormat == "none")
        {
            return ValueTask.FromResult(
                AttestationTrustPolicyResult.Reject(
                    "Direct attestation is required. Your authenticator did not provide identity proof."));
        }

        // Check the AAGUID against our allowlist
        if (!AllowedAaguids.Contains(context.Aaguid))
        {
            return ValueTask.FromResult(
                AttestationTrustPolicyResult.Reject(
                    "This authenticator model is not approved. Please use an approved hardware security key."));
        }

        return ValueTask.FromResult(AttestationTrustPolicyResult.Accept());
    }
}

The rejection messages matter. They'll end up in error responses to the client, so write them for the person holding the wrong key, not for a log file.

Registering the Policy

With the policy class in place, you need to tell Duende User Management to use it. The registration happens in Program.cs alongside the rest of your passkey configuration. There are two settings worth calling out beyond the policy itself:

  • AttestationConveyancePreference = "direct" tells the browser you want the raw attestation data, not a stripped-down version.
  • AuthenticatorAttachment = "cross-platform" filters the browser's registration UI so it only offers roaming authenticators (USB/NFC keys), not built-in platform authenticators like Windows Hello or Touch ID.

Together with the policy, this gives you two layers: the browser filters what the user sees, and the server validates what actually arrives.

C#

builder.Services
    .AddIdentityServer()
    .AddUserManagement(um => um
        .Authentication(auth =>
        {
            auth.AddAttestationTrustPolicy<HardwareKeyOnlyPolicy>();

            auth.Configure(options =>
            {
                // Request direct attestation from authenticators
                options.Passkeys.AttestationConveyancePreference = "direct";

                // Only allow cross-platform (roaming) authenticators
                options.Passkeys.AuthenticatorAttachment = "cross-platform";

                // ... other passkey settings (relying party, origins, etc.)
            });
        })
    );

Showing Useful Errors

When your policy rejects a registration, the user gets an error. Without some effort on your part, that error is going to be cryptic. You probably know the feeling from being on the receiving end of "An error occurred. Please try again."

Duende User Management ships a JavaScript helper that handles the WebAuthn browser calls for you. Include it from the /passkeys/js endpoint that User Management exposes, and then call registerPasskey from your own script, for example wired to a button click. Here's what that looks like on a Razor page or in a plain HTML view:

Html

<div id="status"></div>
<button id="register-key">Register security key</button>

<script src="/passkeys/js"></script>
<script>
    document.getElementById("register-key").addEventListener("click", () => {
        registerPasskey("My Security Key", {
            onStart() {
                document.getElementById("status").textContent = "Starting registration...";
            },
            onWaitingForAuthenticator() {
                document.getElementById("status").textContent = "Touch your security key...";
            },
            onSuccess(result) {
                document.getElementById("status").textContent = "Security key registered!";
            },
            onError(message) {
                const el = document.getElementById("status");
                if (message.includes("not approved")) {
                    el.innerHTML = `
                        <strong>This authenticator is not allowed.</strong><br>
                        Your organization requires a hardware security key (YubiKey or Google Titan).
                        <a href="/help/approved-keys">View approved devices</a>`;
                } else if (message.includes("attestation is required")) {
                    el.innerHTML = `
                        <strong>Authenticator identity could not be verified.</strong><br>
                        Please use a security key that supports attestation.`;
                } else {
                    el.textContent = "Registration failed: " + message;
                }
            }
        });
    });
</script>

Linking to an internal "approved devices" page is a nice touch, so your IT team can keep a list of supported authenticators.

Loading AAGUIDs from Configuration

The hardcoded AAGUID list in the example above works fine when your approved hardware is stable. But authenticator models change, new keys get certified, and your security team probably doesn't want to wait for a code deploy every time they approve a new device.

Moving the allowlist to appsettings.json keeps the policy code generic and lets you update the approved list through configuration alone. This is what your IAttestationTrustPolicy could look like:

C#

public class ConfigurableAttestationPolicy : IAttestationTrustPolicy
{
    private readonly HashSet<Guid> _allowedAaguids;

    public ConfigurableAttestationPolicy(IConfiguration configuration)
    {
        _allowedAaguids = configuration
            .GetSection("Passkeys:AllowedAaguids")
            .Get<string[]>()!
            .Select(Guid.Parse)
            .ToHashSet();
    }

    public ValueTask<AttestationTrustPolicyResult> EvaluateAsync(
        AttestationTrustContext context,
        CancellationToken ct)
    {
        if (context.AttestationFormat == "none")
        {
            return ValueTask.FromResult(
                AttestationTrustPolicyResult.Reject("Direct attestation is required."));
        }

        if (!_allowedAaguids.Contains(context.Aaguid))
        {
            return ValueTask.FromResult(
                AttestationTrustPolicyResult.Reject("Authenticator model not in allowlist."));
        }

        return ValueTask.FromResult(AttestationTrustPolicyResult.Accept());
    }
}

You can back it by adding entries to appsettings.json.

Json

{
  "Passkeys": {
    "AllowedAaguids": [
      "2fc0579f-8113-47ea-b116-bb5a8db9202a",
      "c1f9a0bc-1dd2-404a-b27f-8e29047a43fd",
      "42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3"
    ]
  }
}

You could take this further and load from a database or a remote configuration service if your deployment needs that, but appsettings.json covers most cases. Also keep in mind you likely don't want to make excessive I/O calls to validate the AAGUID, to make sure this doesn't become a denial-of-service opportunity for your identity provider.

Wrap-Up

The building blocks here are straightforward: request attestation, check the AAGUID against a list you control, and give users a clear message when their device doesn't make the cut. If you're in an environment where compliance requires hardware-bound credentials, this is a practical way to enforce it without making life harder for people who have the right key in their pocket. I'll end with a quick checklist for your implementation:

  1. Set AttestationConveyancePreference to "direct" so attestation data actually arrives
  2. Set AuthenticatorAttachment to "cross-platform" to filter the browser UI
  3. Reject "none" attestation to require identity proof
  4. Keep an AAGUID allowlist of approved models (the FIDO MDS and community AAGUID list are good starting points)
  5. Write error messages for humans, not log files

See the Duende User Management passkey documentation for the full API reference, and let us know in the comments what you're building with Duende!

Related Articles