Passkey endpoints for MapIdentityApi()
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 290
Description
Tracked by #67301 and #68199.
## Background and Motivation
`MapIdentityApi()` is the "give me Identity as JSON endpoints" story for SPAs and mobile apps. Identity gained passkeys in .NET 10, but only through `SignInManager`, which those endpoints don't expose. So a React or MAUI app either drops passkeys or hand-writes both WebAuthn ceremonies, including the challenge state that has to survive between the two requests. There is also no way for a signed-in user to see which passkeys are on their account, rename one, or revoke a lost one.
This adds seven endpoints: the two ceremonies, and management for the credentials they produce.
| Route | Auth | Purpose |
|---|---|---|
| `POST /passkeys/requestOptions` | anonymous | assertion options for the browser |
| `POST /passkeys/login` | anonymous | verify the assertion and sign in |
| `POST /manage/passkeys/creationOptions` | authorized | attestation options for the signed-in user |
| `POST /manage/passkeys` | authorized | verify the attestation and store the passkey |
| `GET /manage/passkeys` | authorized | list the signed-in user's passkeys |
| `PUT /manage/passkeys/{credentialId}` | authorized | rename one |
| `DELETE /manage/passkeys/{credentialId}` | authorized | revoke one |
## Proposed API
```diff
namespace Microsoft.AspNetCore.Identity.Data;
+ public sealed class PasskeyRequestOptionsRequest
+ {
+ public PasskeyRequestOptionsRequest();
+ public string? Email { get; init; }
+ }
+
+ public sealed class PasskeyLoginRequest
+ {
+ public PasskeyLoginRequest();
+ public required string CredentialJson { get; init; }
+ }
+
+ public sealed class PasskeyRegistrationRequest
+ {
+ public PasskeyRegistrationRequest();
+ public required string CredentialJson { get; init; }
+ public string? Name { get; init; }
+ }
+
+ public sealed class PasskeyUpdateRequest
+ {
+ public PasskeyUpdateRequest();
+ public string? Name { get; init; }
+ }
+
+ public sealed class PasskeyInfoResponse
+ {
+ public PasskeyInfoResponse();
+ public required string CredentialId { get; init; }
+ public string? Name { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ }
namespace Microsoft.AspNetCore.Identity;
public class SignInManager where TUser : class
{
public virtual Task PasskeySignInAsync(string credentialJson);
+ public virtual Task PasskeySignInAsync(string credentialJson, bool isPersistent);
}
```
The overload exists because `/login` already honours `?useCookies=true`, which needs a persistent cookie, and the shipped `PasskeySignInAsync` hardcodes `isPersistent: false`. Without it, passkey login and password login would behave differently for the same query string.
`PasskeyInfoResponse` is the response for registration, listing and renaming alike. The three are the same shape, so one type keeps the surface small and makes the three responses identical by construction rather than by care.
It carries three of the twelve members on `UserPasskeyInfo`. Those three are what a person needs to recognise a credential in a list and decide which one to revoke. The public key, attestation object, client data, sign count, transports and AAGUID stay server-side. Adding a field later is additive, removing one is not, so the response starts small.
Both ceremonies keep their challenge in the existing `Identity.TwoFactorUserId` cookie, which `SignInManager` already uses and `AddIdentityApiEndpoints` already registers. An app that wires up bearer tokens only, via `AddIdentityCore().AddApiEndpoints()`, has no such scheme and gets an `InvalidOperationException`, exactly as `/login?useCookies=true` does today.
## Usage Examples
No new C# at the call site. `MapIdentityApi()` is unchanged:
```csharp
builder.Services.AddIdentityApiEndpoints()
.AddEntityFrameworkStores();
app.MapGroup("/identity").MapIdentityApi();
```
The interesting side is the client. Registering a passkey for the signed-in user:
```js
const options = await fetch('/identity/manage/passkeys/creationOptions', {
method: 'POST',
credentials: 'include',
}).then(r => r.json());
const credential = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
});
const { credentialId } = await fetch('/identity/manage/passkeys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialJson: JSON.stringify(credential), name: 'Laptop' }),
}).then(r => r.json());
```
Signing in with one, asking for a persistent cookie:
```js
const options = await fetch('/identity/passkeys/requestOptions', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}).then(r => r.json());
const credential = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
});
await fetch('/identity/passkeys/login?useCookies=true', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialJson: JSON.stringify(credential) }),
});
```
`credentials: 'include'` matters on every call: it is what carries the ceremony-state cookie from the options request to the completion request.
Building a management screen:
```js
const passkeys = await fetch('/identity/manage/passkeys', {
credentials: 'include',
}).then(r => r.json());
// [{ credentialId: 'AQID', name: 'Laptop', createdAt: '2026-08-25T09:20:28+00:00' }]
await fetch(`/identity/manage/passkeys/${passkeys[0].credentialId}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Work laptop' }),
});
await fetch(`/identity/manage/passkeys/${passkeys[0].credentialId}`, {
method: 'DELETE',
credentials: 'include',
});
```
## Alternative Designs
**Add `isPersistent` as an optional parameter to the shipped `PasskeySignInAsync(string)`.** Smaller surface, but it is a binary break: callers compiled against .NET 10 have the one-argument signature baked into their assembly and would fail at runtime.
**Extend `LoginRequest` with passkey fields instead of adding new types.** `Email` and `Password` on `LoginRequest` are `required`, so a passkey client would have to send dummy values for both, and `/login` would grow a second ceremony behind one route. Separate routes and types keep each request meaning one thing.
**One shared credential type for login and registration.** They differ already: `Name` is meaningless when signing in, and registration returns a credential ID while login returns a token or a cookie. Two types cost nothing and let each side change on its own.
**A separate response type for registration, distinct from the list response.** Registration would keep returning a credential ID and a name while a near-identical list type shipped beside it. The shapes are the same and `CreatedAt` is known at registration time, so this would be two permanent types for one concept.
**`POST /manage/passkeys/{id}/rename` and `/remove`.** This avoids adding verbs to a file that is otherwise all `POST` and `GET`, but it invents RPC-shaped paths where `PUT` and `DELETE` say the same thing about an identified subresource.
**`PATCH` for rename.** Partial-update semantics on a resource with one mutable property, and it needs a "field absent" versus "field set to null" distinction the DTO cannot express.
**Returning more of `UserPasskeyInfo`, such as `Transports` or `IsBackedUp`.** These would let a UI say "synced" versus "device-bound", which is a real thing to want. I left them out because they are additive later and I would rather add a field on demand than carry one nobody uses.
## Risks
**Binary breaking: none.** `PasskeySignInAsync(string)` keeps its exact signature and stays virtual. The new overload is purely additive, and the shipped one now forwards to it with `isPersistent: false`.
**Source breaking: one narrow case.** A natural-typed method group over `PasskeySignInAsync` no longer resolves, because there are two candidates:
```csharp
var f = signInManager.PasskeySignInAsync; // was fine, now CS8917
```
The fix is a target type, `Func> f = signInManager.PasskeySignInAsync;`. I compiled all four patterns against the change: direct calls, target-typed method groups and `override Task PasskeySignInAsync(string)` on a derived manager all still build with zero warnings. Only the `var` form breaks.
**Interface changes: none.** `IPasskeyHandler` is untouched, and no method is added to `IUserPasskeyStore`, which is already shipped.
**Security.** The ceremony state travels in the data-protected `Identity.TwoFactorUserId` cookie, so a client cannot tamper with the challenge or with the user entity it is registering against. Registration sits behind `RequireAuthorization()` and additionally compares the attested user entity ID with the signed-in user's ID, so a client cannot register a passkey onto someone else's account even if it replays another user's attestation state.
Rename and delete resolve the signed-in user first and then look the credential up scoped to that user, before touching anything. This matters more than it looks: the EF store resolves a credential ID globally with no user filter, and the remove path has no existence check, so a handler that trusted the route value would let one user rename another user's passkey and would return 200 for a delete that deleted nothing. An unknown credential and someone else's credential are indistinguishable from outside: both are 404.
A malformed credential ID is a 400 rather than a 404. Whether a string is valid Base64Url is decidable offline with a regex, so saying so reveals nothing, and the request never reaches a lookup, so 404 would be claiming knowledge the server does not have. The two cases that a caller genuinely cannot tell apart, absent and foreign, are the ones that share a status code.
The options endpoints do not reveal whether an account exists. Passkey login bypasses two-factor, which is the shipped `SignInManager` behaviour and is not changed here.
Contributor guide
Research direction
Start at MapIdentityApi() and the existing SignInManager.PasskeySignInAsync(string), then inspect AddIdentityApiEndpoints and the Identity.TwoFactorUserId cookie flow. Trace IPasskeyHandler and IUserPasskeyStore for ceremony and user-scoped management behavior. Done means the seven passkey routes and persistent-login overload match the proposed API and preserve the stated authorization and 404 behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, javascript
- Domain
- api, authentication, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100