Background and Motivation
Passkey telemetry was added a piece at a time. Two things are missing today.
A failed passkey registration is not visible in any metric. The only registration metric is aspnetcore.identity.user.update.duration tagged add_or_update_passkey, and it only fires once attestation has already succeeded. If every registration in an app started failing, no chart would move. Assertion is covered, but only because PasskeySignInAsync happens to wrap it.
No failure carries a reason you can count. PasskeyHandler has 37 places it can reject a credential, and all of them end up in one LogDebug with the message interpolated as text. There is no bounded value to chart or alert on.
This proposes two instruments on the existing Microsoft.AspNetCore.Identity meter. There is no public C# API surface change: the reviewable surface here is the telemetry contract.
Proposed API
Microsoft.AspNetCore.Identity
aspnetcore.identity.passkey.ceremony.duration
| Name |
Instrument Type |
Unit |
Description |
aspnetcore.identity.passkey.ceremony.duration |
Histogram |
s |
The duration of passkey ceremonies. Recorded by the SignInManager.PerformPasskeyAttestationAsync and SignInManager.PerformPasskeyAssertionAsync methods. |
| Attribute |
Type |
Description |
Examples |
Presence |
aspnetcore.identity.user_type |
string |
The identity user type. |
ContosoUser |
Always |
aspnetcore.identity.passkey.ceremony |
string |
The passkey ceremony being performed. |
attestation; assertion |
Always |
aspnetcore.identity.passkey.result |
string |
Whether the ceremony succeeded, failed or was canceled. |
success; failure; canceled |
Always |
aspnetcore.identity.passkey.failure_reason |
string |
The reason the ceremony failed. |
origin_mismatch; rejected |
If aspnetcore.identity.passkey.result is failure. |
error.type |
string |
The full name of exception type. |
System.InvalidOperationException |
If an exception was thrown. |
aspnetcore.identity.passkey.failure_reason takes one of:
ceremony_state_invalid, malformed_input, origin_mismatch, rp_id_hash_mismatch, challenge_mismatch, signature_invalid, sign_count_invalid, user_verification_failed, rejected, unexpected_error, _OTHER.
Two of those are only reachable during assertion, because only assertion verifies a signature against a stored credential: signature_invalid and sign_count_invalid. The attestation-only failures, such as an invalid attestation statement or a credential that is already registered, fall under rejected rather than getting their own values, so that the metric does not distinguish a credential that exists from one that does not.
When assertion runs as part of PasskeySignInAsync, the nested measurement is suppressed and only aspnetcore.identity.sign_in.authenticate.duration records, so the two do not double count. That instrument gains the same aspnetcore.identity.passkey.failure_reason attribute, present when a passkey sign-in fails during the ceremony.
aspnetcore.identity.passkey.generated_options
| Name |
Instrument Type |
Unit |
Description |
aspnetcore.identity.passkey.generated_options |
Counter |
{option} |
The total number of passkey ceremony options generated. Recorded by the SignInManager.MakePasskeyCreationOptionsAsync and SignInManager.MakePasskeyRequestOptionsAsync methods. |
| Attribute |
Type |
Description |
Examples |
Presence |
aspnetcore.identity.user_type |
string |
The identity user type. |
ContosoUser |
Always |
aspnetcore.identity.passkey.ceremony |
string |
The passkey ceremony the options are for. |
attestation; assertion |
Always |
error.type |
string |
The full name of exception type. |
System.InvalidOperationException |
If an exception was thrown. |
Named for options generated rather than ceremonies started, because handing out options does not establish that a browser ever used them. The gap between the two requests is in an operating system prompt the server cannot observe.
Failure reason values
The reason travels internally on PasskeyException and is mapped to a tag value the same way SignInManagerMetrics.GetSignInType maps sign in types:
internal enum PasskeyFailureReason
{
Other,
CeremonyStateInvalid,
MalformedInput,
OriginMismatch,
RelyingPartyIdHashMismatch,
ChallengeMismatch,
SignatureInvalid,
SignCountInvalid,
UserVerificationFailed,
Rejected,
UnexpectedError,
}
private static string GetPasskeyFailureReason(PasskeyFailureReason reason)
{
return reason switch
{
PasskeyFailureReason.CeremonyStateInvalid => "ceremony_state_invalid",
PasskeyFailureReason.MalformedInput => "malformed_input",
PasskeyFailureReason.OriginMismatch => "origin_mismatch",
PasskeyFailureReason.RelyingPartyIdHashMismatch => "rp_id_hash_mismatch",
PasskeyFailureReason.ChallengeMismatch => "challenge_mismatch",
PasskeyFailureReason.SignatureInvalid => "signature_invalid",
PasskeyFailureReason.SignCountInvalid => "sign_count_invalid",
PasskeyFailureReason.UserVerificationFailed => "user_verification_failed",
PasskeyFailureReason.Rejected => "rejected",
PasskeyFailureReason.UnexpectedError => "unexpected_error",
_ => "_OTHER"
};
}
PasskeyFailureReason stays internal. A custom IPasskeyHandler<TUser> constructing its own PasskeyException produces _OTHER.
Logging
Not part of the API surface, included so the whole design is in one place. Passkey logs move off the SignInManager<TUser> category so that turning them up does not turn every password sign in up with them:
// Category: Microsoft.AspNetCore.Identity.Passkeys
[LoggerMessage(8, LogLevel.Debug, "Passkey attestation failed. Reason: {Reason}. RelyingPartyId: {RelyingPartyId}.", EventName = "PasskeyAttestationFailed")]
public static partial void PasskeyAttestationFailed(ILogger logger, string reason, string relyingPartyId);
[LoggerMessage(9, LogLevel.Debug, "Passkey assertion failed. Reason: {Reason}. RelyingPartyId: {RelyingPartyId}.", EventName = "PasskeyAssertionFailed")]
public static partial void PasskeyAssertionFailed(ILogger logger, string reason, string relyingPartyId);
Event ids and names 8 and 9 are unchanged. {RelyingPartyId} is the configured IdentityPasskeyOptions.ServerDomain, not a value from the request. PasskeyException.Message is never logged, at any level. Debug, because a rejected passkey is the server working correctly.
EventIds.cs also declares NoPasskeyCreationOptions (6) and UserDoesNotMatchPasskeyCreationOptions (7), which are referenced nowhere in src/Identity. No log line has ever carried them, so I would delete them.
Usage Examples
Alerting on the failures worth paging for. An origin or relying party mismatch spike means phishing or a misconfigured relying party:
sum(rate(aspnetcore_identity_passkey_ceremony_duration_count{
aspnetcore_identity_passkey_failure_reason=~"origin_mismatch|rp_id_hash_mismatch"
}[5m])) > 0
Registration health, which there is no way to chart today:
sum by (aspnetcore_identity_passkey_result) (
rate(aspnetcore_identity_passkey_ceremony_duration_count{
aspnetcore_identity_passkey_ceremony="attestation"
}[5m])
)
Turning passkey logging up on its own:
{
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.Identity.Passkeys": "Debug"
}
}
}
dbug: Microsoft.AspNetCore.Identity.Passkeys[8]
Passkey attestation failed. Reason: origin_mismatch. RelyingPartyId: contoso.com
Alternative Designs
One instrument per ceremony, passkey.attestation.duration and passkey.assertion.duration, instead of one with a ceremony attribute. Both ceremonies are the same operation, a server side verification of a WebAuthn response, with the same timing profile, so a single set of histogram buckets suits both and the total across them is a meaningful number. A consumer can always split on the attribute, but combining two instruments is awkward and not portable between backends. This also matches aspnetcore.identity.sign_in.authenticate.duration, which covers password, two factor, external and passkey under one aspnetcore.identity.sign_in.type attribute.
A public failure reason. PasskeyAttestationResult.Failure is public, so exposing the reason is possible. It commits us to a taxonomy that WebAuthn keeps moving, and once apps can read it some will show it to users, where telling "already registered" from "unknown credential" reveals whether an account exists. Internal now can become public later if a real use case appears; the reverse is not true.
No aspnetcore.authentication.scheme attribute, unlike the sign in metrics. A ceremony is credential verification only. No scheme is involved until a sign in follows it.
Risks
Double counting. Summing aspnetcore.identity.passkey.ceremony.duration and aspnetcore.identity.sign_in.authenticate.duration would count a failed passkey sign in twice. Suppressing the nested assertion measurement is what prevents that.
Background and Motivation
Passkey telemetry was added a piece at a time. Two things are missing today.
A failed passkey registration is not visible in any metric. The only registration metric is
aspnetcore.identity.user.update.durationtaggedadd_or_update_passkey, and it only fires once attestation has already succeeded. If every registration in an app started failing, no chart would move. Assertion is covered, but only becausePasskeySignInAsynchappens to wrap it.No failure carries a reason you can count.
PasskeyHandlerhas 37 places it can reject a credential, and all of them end up in oneLogDebugwith the message interpolated as text. There is no bounded value to chart or alert on.This proposes two instruments on the existing
Microsoft.AspNetCore.Identitymeter. There is no public C# API surface change: the reviewable surface here is the telemetry contract.Proposed API
Microsoft.AspNetCore.Identity
aspnetcore.identity.passkey.ceremony.durationaspnetcore.identity.passkey.ceremony.durationsaspnetcore.identity.user_typeContosoUseraspnetcore.identity.passkey.ceremonyattestation;assertionaspnetcore.identity.passkey.resultsuccess;failure;canceledaspnetcore.identity.passkey.failure_reasonorigin_mismatch;rejectedaspnetcore.identity.passkey.resultisfailure.error.typeSystem.InvalidOperationExceptionaspnetcore.identity.passkey.failure_reasontakes one of:ceremony_state_invalid,malformed_input,origin_mismatch,rp_id_hash_mismatch,challenge_mismatch,signature_invalid,sign_count_invalid,user_verification_failed,rejected,unexpected_error,_OTHER.Two of those are only reachable during assertion, because only assertion verifies a signature against a stored credential:
signature_invalidandsign_count_invalid. The attestation-only failures, such as an invalid attestation statement or a credential that is already registered, fall underrejectedrather than getting their own values, so that the metric does not distinguish a credential that exists from one that does not.When assertion runs as part of
PasskeySignInAsync, the nested measurement is suppressed and onlyaspnetcore.identity.sign_in.authenticate.durationrecords, so the two do not double count. That instrument gains the sameaspnetcore.identity.passkey.failure_reasonattribute, present when a passkey sign-in fails during the ceremony.aspnetcore.identity.passkey.generated_optionsaspnetcore.identity.passkey.generated_options{option}aspnetcore.identity.user_typeContosoUseraspnetcore.identity.passkey.ceremonyattestation;assertionerror.typeSystem.InvalidOperationExceptionNamed for options generated rather than ceremonies started, because handing out options does not establish that a browser ever used them. The gap between the two requests is in an operating system prompt the server cannot observe.
Failure reason values
The reason travels internally on
PasskeyExceptionand is mapped to a tag value the same waySignInManagerMetrics.GetSignInTypemaps sign in types:PasskeyFailureReasonstays internal. A customIPasskeyHandler<TUser>constructing its ownPasskeyExceptionproduces_OTHER.Logging
Not part of the API surface, included so the whole design is in one place. Passkey logs move off the
SignInManager<TUser>category so that turning them up does not turn every password sign in up with them:Event ids and names 8 and 9 are unchanged.
{RelyingPartyId}is the configuredIdentityPasskeyOptions.ServerDomain, not a value from the request.PasskeyException.Messageis never logged, at any level. Debug, because a rejected passkey is the server working correctly.EventIds.csalso declaresNoPasskeyCreationOptions(6) andUserDoesNotMatchPasskeyCreationOptions(7), which are referenced nowhere insrc/Identity. No log line has ever carried them, so I would delete them.Usage Examples
Alerting on the failures worth paging for. An origin or relying party mismatch spike means phishing or a misconfigured relying party:
Registration health, which there is no way to chart today:
Turning passkey logging up on its own:
{ "Logging": { "LogLevel": { "Microsoft.AspNetCore.Identity.Passkeys": "Debug" } } }Alternative Designs
One instrument per ceremony,
passkey.attestation.durationandpasskey.assertion.duration, instead of one with aceremonyattribute. Both ceremonies are the same operation, a server side verification of a WebAuthn response, with the same timing profile, so a single set of histogram buckets suits both and the total across them is a meaningful number. A consumer can always split on the attribute, but combining two instruments is awkward and not portable between backends. This also matchesaspnetcore.identity.sign_in.authenticate.duration, which covers password, two factor, external and passkey under oneaspnetcore.identity.sign_in.typeattribute.A public failure reason.
PasskeyAttestationResult.Failureis public, so exposing the reason is possible. It commits us to a taxonomy that WebAuthn keeps moving, and once apps can read it some will show it to users, where telling "already registered" from "unknown credential" reveals whether an account exists. Internal now can become public later if a real use case appears; the reverse is not true.No
aspnetcore.authentication.schemeattribute, unlike the sign in metrics. A ceremony is credential verification only. No scheme is involved until a sign in follows it.Risks
Double counting. Summing
aspnetcore.identity.passkey.ceremony.durationandaspnetcore.identity.sign_in.authenticate.durationwould count a failed passkey sign in twice. Suppressing the nested assertion measurement is what prevents that.