Custom Passkey Attestation Policies: Restricting Login to Hardware Keys
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'sIAttestationTrustPolicyinterface lets you reject passkey registrations at the server before credentials are stored.
• SetAttestationConveyancePreferenceto"direct"so attestation data actually reaches your server.
• Build an AAGUID allowlist of approved hardware authenticator models and reject everything else.
• Move the allowlist toappsettings.jsonor 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 | |
| YubiKey 5C | |
| Google Titan (USB-A) | |
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:
- Set
AttestationConveyancePreferenceto"direct"so attestation data actually arrives - Set
AuthenticatorAttachmentto"cross-platform"to filter the browser UI - Reject
"none"attestation to require identity proof - Keep an AAGUID allowlist of approved models (the FIDO MDS and community AAGUID list are good starting points)
- 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!