Skip to content

Passkey telemetry: proposed metrics, tags and logging #69223

Description

@rolandVi

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-proposalapi-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-identityIncludes: Identity and providersdesign-proposalThis issue represents a design proposal for a different issue, linked in the description

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions