Passkey telemetry: proposed metrics, tags and logging
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 276
Description
## 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:
```csharp
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` 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` category so that turning them up does not turn every password sign in up with them:
```csharp
// 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:
```json
{
"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.
Contributor guide
Research direction
Start in src/Identity by tracing SignInManager.PerformPasskeyAttestationAsync, PerformPasskeyAssertionAsync, MakePasskeyCreationOptionsAsync, and MakePasskeyRequestOptionsAsync, then inspect PasskeyHandler and SignInManagerMetrics. Review EventIds.cs and the existing passkey logging category. Done means the proposed instruments, bounded failure reasons, separated logging, and nested-measurement suppression are implemented without double counting.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend, observability
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100