"Passkeys are a trap. Do not use them." Met die titel stond een Substack-stuk dit weekend bovenaan Hacker News, met ruim 500 punten en bijna 300 reacties. De auteur geeft toe dat passkeys veiliger en makkelijker zijn dan wachtwoorden. Het bezwaar zit ergens anders: via remote attestation kan een website eisen dat je passkey uit een "goedgekeurde" passkey-manager op een "goedgekeurd" besturingssysteem komt. Wie LineageOS draait of zijn sleutels in een eigen manager bewaart, valt dan buiten de boot.
Als gebruiker kun je daar lang over discussiëren. Als developer ligt het simpeler. De partij die attestation eist, is de relying party. Dat is de server die de passkey registreert en controleert. Bouw je login in ASP.NET Core, dan ben jij dat. De val uit het artikel gaat alleen dicht als iemand hem zet, en die iemand zit in jouw Program.cs.
Wat het artikel precies beweert
Het stuk maakt een paar punten die je los van elkaar moet bekijken.
Het eerste is dat "passkey" een paraplu is. De FIDO Alliance gebruikt één woord voor credentials die wel of niet te kopiëren zijn, wel of niet in hardware zitten, wel of niet gesynchroniseerd worden. Dat klopt, en het maakt het gesprek met gebruikers verwarrend.
Het tweede punt is de kern: attestation. Bij registratie kan een authenticator een ondertekende verklaring meesturen over wat voor apparaat hij is. Een server die die verklaring controleert tegen een lijst vertrouwde fabrikanten, kan alles weigeren wat niet op de lijst staat. De auteur ziet daarin hetzelfde mechanisme als bij app-integriteitschecks op Android, waar een telefoon met een eigen ROM ineens niet meer mag bankieren.
Het advies van de auteur is om voorlopig bij TOTP-apps of hardware-sleutels zoals een YubiKey te blijven.
In de HN-discussie kwam flink wat tegengas. Mensen uit de auth-hoek wezen erop dat vrijwel geen consumentendienst attestation eist, omdat elke extra drempel bij het inloggen je conversie kost. Anderen noemden de nieuwe FIDO-specificaties voor credential exchange (CXP en CXF), waarmee je passkeys tussen managers als Bitwarden en 1Password kunt verhuizen. En een paar reageerders zeiden dat de echte zorg bij banken ligt, niet bij Netflix. Banken hebben namelijk wel een compliance-reden om apparaten te willen kennen.
Mijn lezing: de auteur heeft gelijk dat het mechanisme bestaat. Of het misbruikt wordt, beslissen de mensen die relying parties bouwen. Dus wij.
Wat ASP.NET Core Identity standaard doet
Sinds .NET 10 heeft ASP.NET Core Identity ingebouwde passkey-ondersteuning. Microsoft is in de documentatie opvallend duidelijk: attestation statements worden standaard niet gevalideerd, en dat noemen ze geschikt voor de meeste consumentenscenario's. Wil je toch controleren, dan hang je een eigen callback in VerifyAttestationStatement, met de waarschuwing dat je dan trust stores met fabriekscertificaten moet bijhouden.
Dat is een gezonde default. Toch zou ik hem expliciet maken, zodat een collega over twee jaar niet denkt dat het een vergeten instelling is:
builder.Services
.AddIdentityCore<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
// Passkey-tabellen komen pas met schema versie 3
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
options.ServerDomain = "app.example.nl";
// Bewuste keuze: we vragen geen attestation. Elke passkey-manager
// die WebAuthn spreekt is welkom, ook die van de gebruiker zelf.
options.AttestationConveyancePreference = "none";
// Wel eisen dat de gebruiker zich bij de authenticator verifieert
// (pincode, vingerafdruk), anders is het alleen "iets bezitten".
options.UserVerificationRequirement = "required";
options.ResidentKeyRequirement = "preferred";
// AuthenticatorAttachment laten we op null: platform-authenticators
// en losse security keys zijn allebei toegestaan.
});
"none" is ook wat de WebAuthn-spec aanneemt als je niets opgeeft. Het verschil is leesbaarheid. Een expliciete regel met commentaar is een beslissing. Een ontbrekende regel is een gok over defaults.
Let ook op AuthenticatorAttachment. Zet je die op "platform", dan sluit je hardware-sleutels uit. Dat is precies de groep gebruikers die het artikel aanraadt om een YubiKey te nemen. Je zou ze dus wegjagen met een instelling die op het eerste gezicht nergens over gaat.
De echte val: passkey als enige deur
Na een dag lezen in de HN-thread was mijn conclusie dat attestation in de praktijk het kleinere probleem is. Het grotere probleem is account recovery. Een paar reageerders beschreven hoe ze na een geblokkeerd Apple-account ook bij diensten niet meer binnenkwamen, omdat hun enige passkey in die sleutelhanger zat.
Dat is een ontwerpkeuze aan jouw kant. Microsoft schrijft het zelf ook: herstel is vooral een zorg voor apps die passkeys als enige authenticatiemethode toestaan. De Blazor-template laat gebruikers daarom altijd een wachtwoord of externe provider als back-up instellen. Bouw je zelf een passwordless flow, dan noemt de documentatie recovery codes, herstel via e-mail, verplicht meerdere passkeys registreren en het in de gaten houden van de backup-vlag.
De goedkoopste bescherming is een regel die voorkomt dat iemand zijn laatste deur dichtgooit:
app.MapPost("/Account/Passkeys/Remove", async (
[FromForm] string credentialId,
HttpContext context,
UserManager<ApplicationUser> userManager) =>
{
var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.Unauthorized();
}
var id = Base64Url.DecodeFromChars(credentialId);
var passkeys = await userManager.GetPasskeysAsync(user);
var hasPassword = await userManager.HasPasswordAsync(user);
var hasExternalLogin = (await userManager.GetLoginsAsync(user)).Count > 0;
var isLastWayIn = passkeys.Count == 1 && !hasPassword && !hasExternalLogin;
if (isLastWayIn)
{
return Results.Problem(
title: "Dit is je laatste manier om in te loggen",
detail: "Voeg eerst een tweede passkey of een wachtwoord toe.",
statusCode: StatusCodes.Status409Conflict);
}
var result = await userManager.RemovePasskeyAsync(user, id);
return result.Succeeded ? Results.NoContent() : Results.BadRequest();
}).RequireAuthorization();
Base64Url komt uit System.Buffers.Text en zit sinds .NET 9 in de BCL. Gebruik je de Blazor-template, dan zit er antiforgery op formulieren. Bij een losse minimal API moet je daar zelf aan denken.
IsBackedUp is je beste signaal
Elke opgeslagen passkey in Identity is een UserPasskeyInfo. Daarin zitten twee vlaggen die rechtstreeks uit de authenticator data komen. IsBackupEligible zegt of de credential gesynchroniseerd mag worden. IsBackedUp zegt of dat op dit moment ook zo is.
Een passkey die niet gesynchroniseerd is, zit vast aan één apparaat. Dat is veilig, en ook kwetsbaar: telefoon kwijt, passkey kwijt. Met die informatie kun je de gebruiker helpen zonder hem iets op te leggen:
public sealed record PasskeyHealth(int Count, bool AnyBackedUp, bool NeedsNudge);
public static class PasskeyHealthCheck
{
public static async Task<PasskeyHealth> EvaluateAsync(
UserManager<ApplicationUser> userManager,
ApplicationUser user)
{
var passkeys = await userManager.GetPasskeysAsync(user);
var anyBackedUp = passkeys.Any(p => p.IsBackedUp);
// Eén passkey zonder backup en geen wachtwoord: één verloren
// telefoon en dit account is weg.
var needsNudge = passkeys.Count < 2
&& !anyBackedUp
&& !await userManager.HasPasswordAsync(user);
return new PasskeyHealth(passkeys.Count, anyBackedUp, needsNudge);
}
}
Laat bij NeedsNudge een melding zien op de accountpagina: "Je hebt één passkey, op één apparaat. Voeg een tweede toe." Geen modaal venster dat inloggen blokkeert. De HN-reacties stonden vol met klachten over diensten die passkeys halverwege een betaling opdringen. Dat wil je niet zijn.
Na elke login wordt de vlag ook bijgewerkt. PasskeySignInAsync regelt dat voor je. Gebruik je PerformPasskeyAssertionAsync direct, dan moet je de teruggegeven passkey zelf opslaan met AddOrUpdatePasskeyAsync, anders veroudert je data.
Een AAGUID-inventaris zonder iemand buiten te sluiten
Er is één legitieme reden om naar het type authenticator te kijken: een model blijkt lek. De Microsoft-docs noemen dat scenario expliciet. Identity bewaart het volledige attestation object bij elke passkey, inclusief de AAGUID, een 128-bits id van het authenticatormodel.
Je kunt die uitlezen zonder attestation af te dwingen. De authData in het CBOR-object begint met 32 bytes rpIdHash, 1 byte flags en 4 bytes signCount. Daarna volgt, als de AT-vlag aan staat, de AAGUID van 16 bytes:
using System.Formats.Cbor;
public static class Aaguid
{
private const byte AttestedCredentialDataFlag = 0x40;
public static Guid? Read(ReadOnlyMemory<byte> attestationObject)
{
var reader = new CborReader(attestationObject, CborConformanceMode.Lax);
var entries = reader.ReadStartMap()
?? throw new InvalidOperationException("Onverwachte CBOR-structuur.");
for (var i = 0; i < entries; i++)
{
var key = reader.ReadTextString();
if (key != "authData")
{
reader.SkipValue();
continue;
}
var authData = reader.ReadByteString();
if (authData.Length < 53 || (authData[32] & AttestedCredentialDataFlag) == 0)
{
return null;
}
return new Guid(authData.AsSpan(37, 16), bigEndian: true);
}
return null;
}
}
Voeg het NuGet-pakket System.Formats.Cbor toe en je kunt over je gebruikers heen lopen:
var inventory = new Dictionary<Guid, int>();
foreach (var user in await db.Users.ToListAsync(ct))
{
foreach (var passkey in await userManager.GetPasskeysAsync(user))
{
var aaguid = Aaguid.Read(passkey.AttestationObject) ?? Guid.Empty;
inventory[aaguid] = inventory.GetValueOrDefault(aaguid) + 1;
}
}
Bij een grote gebruikersbasis wil je dit in batches doen. Voor een B2B-app met een paar duizend accounts werkt het zo prima.
Twee kanttekeningen. Zonder gevalideerde attestation is de AAGUID wat de client zegt dat hij is. Dat is goed genoeg voor inventaris en voor het gericht laten vervangen van een kwetsbaar model. Toegang baseer je er niet op. Daarnaast vullen sommige platforms de AAGUID met nullen. Die groep komt bij Guid.Empty terecht. Voor een telling maakt dat niets uit.
Wanneer je attestation wel wilt
Er zijn situaties waar een allowlist van authenticators verdedigbaar is. Een interne beheeromgeving waar het beleid zegt dat alleen door IT uitgedeelde FIPS-sleutels toegang geven, bijvoorbeeld. Daar is de gebruiker een medewerker en het apparaat bedrijfseigendom. Dan is controle de bedoeling.
Ga je die kant op, schrijf de validatie dan niet zelf. Gebruik VerifyAttestationStatement als haakje en laat het echte werk doen door een library die de FIDO Metadata Service en certificaatketens kent, zoals fido2-net-lib. En houd die eis weg bij je klantgerichte login. Zodra een consumenten-app attestation verplicht stelt, bouw je precies de val waar het artikel voor waarschuwt.
Een simpele grens werkt: aparte hostnamen, en daarmee aparte relying party-id's, met aparte IdentityPasskeyOptions. admin.example.nl mag streng zijn, app.example.nl niet. Omdat een passkey gebonden is aan de RP-id, lopen die twee werelden ook technisch niet door elkaar.
Mijn advies
Raad je gebruikers geen passkeys af. Een TOTP-code kan een phishingsite gewoon doorsturen. Een passkey is gebonden aan het domein en werkt op een nepsite simpelweg niet. Dat voordeel is te groot om op te geven om een risico dat jij als bouwer zelf in de hand hebt.
Wat ik wel doe bij klanten: attestation expliciet op "none" zetten met commentaar erbij, geen beperking op het type authenticator, nooit de laatste inlogmethode laten verwijderen, gebruikers met één niet-gesynchroniseerde passkey vriendelijk wijzen op een tweede, en de AAGUID's bijhouden voor de dag dat er een advisory over een sleutelmodel verschijnt.
Het artikel op HN maakt een terecht punt over macht. Alleen ligt die macht voor een groot deel bij de mensen die dit lezen.
Veelgestelde vragen over passkeys en attestation in .NET
Valideert ASP.NET Core Identity standaard de attestation van een passkey?
Nee. Vanaf .NET 10 controleert Identity bij registratie wel de challenge, de origin, de vlaggen voor user presence en verificatie en de publieke sleutel, maar het attestation statement zelf wordt standaard niet gevalideerd. Wil je dat wel, dan registreer je een eigen callback via IdentityPasskeyOptions.VerifyAttestationStatement.
Kan ik passkeys toevoegen aan bestaande accounts met een wachtwoord?
Ja, dat is het meest gebruikelijke scenario. Een ingelogde gebruiker roept MakePasskeyCreationOptionsAsync aan via een endpoint, de browser maakt de credential aan en de server slaat hem op met PerformPasskeyAttestationAsync en AddOrUpdatePasskeyAsync. Het wachtwoord blijft als back-up bestaan totdat jij of de gebruiker besluit het te verwijderen.
Wat is het verschil tussen IsBackupEligible en IsBackedUp?
IsBackupEligible wordt bij registratie vastgelegd en zegt of de authenticator deze passkey mag synchroniseren, zoals een cloud-sleutelhanger doet. IsBackedUp zegt of dat op dit moment echt gebeurd is en kan per login veranderen, daarom moet je de passkey na elke assertion opnieuw opslaan als je niet PasskeySignInAsync gebruikt.
Is TOTP veiliger omdat de gebruiker er meer controle over heeft? Qua controle wel, qua phishing niet. Een TOTP-code die iemand op een nepsite invult kan direct worden doorgestuurd naar de echte site, terwijl een passkey cryptografisch aan het domein vastzit en op een ander domein niets oplevert. Voor de meeste consumentenapps weegt dat zwaarder dan de theoretische kans op attestation-eisen.
Moet ik migreren als ik al fido2-net-lib gebruik? Niet per se. fido2-net-lib geeft je meer controle, onder andere over attestation-validatie met de FIDO Metadata Service, terwijl de ingebouwde Identity-API's minder code en minder afhankelijkheden betekenen. Draait je huidige implementatie goed en heb je die validatie nodig, dan is er weinig reden om te wisselen.
Verder lezen
- SAML is een fractal van slecht ontwerp: wat dat betekent voor je .NET-SSO
- API Security Best Practices: Bescherm je REST APIs
- API Secrets Roteren Zonder Downtime: Een Praktische Aanpak
Conclusie: De val uit het artikel is attestation, en die zet de relying party. Bouw je passkeys in ASP.NET Core Identity, zet attestation dan zichtbaar op "none", laat elk type authenticator toe, zorg dat niemand zijn laatste inlogmethode kwijtraakt, en bewaar strenge eisen voor interne omgevingen waar je de apparaten zelf uitdeelt.
Bronnen: Passkeys are a trap (nonfunctional, Substack) · Hacker News-discussie · Microsoft Learn: passkeys in ASP.NET Core.
