Account Self-Service API
The account self-service API is available at /api/account (configurable via
OpenIddictEndpointsOptions.AccountRoutePrefix). All endpoints use MapGranitGroup()
for automatic FluentValidation. Endpoints are registered by calling
app.MapOpenIddictEndpoints().
Endpoint summary
Section titled “Endpoint summary”| Group | Endpoints | Auth required |
|---|---|---|
| Registration | 3 | Anonymous / Authenticated |
| Profile | 2 | Authenticated |
| Password | 3 | Mixed |
| Two-factor | 5 | Authenticated |
| External logins | 4 | Mixed |
| Passkeys | 6 | Mixed |
| Session | 2 | Authenticated |
| Deletion | 1 | Authenticated |
Registration and email confirmation
Section titled “Registration and email confirmation”Register
Section titled “Register”POST /api/account/registerCreates a new user account. Sends a confirmation email via IEmailConfirmationService.
Publishes UserRegisteredEto via IDistributedEventBus. Increments
granit.openiddict.registrations metric.
Request:
{ "password": "MyStr0ng!Pass", "firstName": "Alice", "lastName": "Doe"}Responses:
201 Createdwith body:
{ "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "requiresEmailConfirmation": true}409 Conflict— email already taken422 Validation Problem— invalid email or weak password
Confirm email
Section titled “Confirm email”GET /api/account/confirm-email?userId=...&token=...Validates the token from the confirmation email link. Returns 204 No Content on
success, 400 Bad Request if the token is invalid or expired.
Resend confirmation email
Section titled “Resend confirmation email”POST /api/account/resend-confirmation-emailResends the confirmation email to the authenticated user. Always returns 202 Accepted regardless of outcome to prevent email enumeration attacks.
Profile
Section titled “Profile”Get profile
Section titled “Get profile”GET /api/account/profileReturns the authenticated user’s profile.
Response:
{ "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "emailConfirmed": true, "firstName": "Alice", "lastName": "Doe", "twoFactorEnabled": false, "hasPassword": true, "externalLogins": ["Google"]}Update profile
Section titled “Update profile”PUT /api/account/profileUpdates firstName and lastName. Returns the updated AccountProfileResponse.
Request:
{ "firstName": "Alice", "lastName": "Smith"}Password management
Section titled “Password management”| Endpoint | Auth | Description |
|---|---|---|
POST /api/account/change-password | Required | Validates current password, sets new one |
POST /api/account/forgot-password | Anonymous | Always returns 202 (prevents enumeration) |
POST /api/account/reset-password | Anonymous | Validates token from email, sets new password |
Change password
Section titled “Change password”Validates the current password before setting the new one.
Request:
{ "currentPassword": "OldP@ss123", "newPassword": "NewSecureP@ss456"}204 No Contenton success400 Bad Requestif current password is incorrect
Forgot password
Section titled “Forgot password”Sends a password reset link to the specified email.
Request:
{}Always returns 202 Accepted regardless of whether the email exists, to prevent user enumeration. If the email does not exist, the request is silently dropped.
Reset password
Section titled “Reset password”Validates the reset token and sets the new password.
Request:
{ "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "token": "CfDJ8...", "newPassword": "NewSecureP@ss456"}204 No Contenton success400 Bad Requestif token is invalid or expired
Two-factor authentication (TOTP)
Section titled “Two-factor authentication (TOTP)”All 2FA endpoints require authentication. The OpenIddict.TwoFactor feature flag
(default: true) controls availability at the tenant level.
Setup flow
Section titled “Setup flow”- Get status:
GET /api/account/two-factor
{ "isEnabled": false, "hasAuthenticatorApp": false, "recoveryCodesLeft": 0}- Get authenticator key:
GET /api/account/two-factor/authenticator-key
{ "sharedKey": "JBSWY3DPEHPK3PXP", "qrCodeUri": "otpauth://totp/MyApp:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp"}The frontend renders a QR code from qrCodeUri for the user to scan with their
authenticator app (Google Authenticator, Authy, 1Password, etc.).
- Enable 2FA:
POST /api/account/two-factor/enable
Request:
{ "code": "123456"}Response (200):
{ "recoveryCodes": [ "ABCD-1234", "EFGH-5678", "IJKL-9012", "MNOP-3456", "QRST-7890", "UVWX-1234", "YZAB-5678", "CDEF-9012", "GHIJ-3456", "KLMN-7890" ]}Returns 400 Bad Request if the TOTP code is invalid.
-
Disable 2FA:
POST /api/account/two-factor/disable— returns204 No Content -
Regenerate recovery codes:
POST /api/account/two-factor/recovery-codes
Generates 10 new single-use recovery codes. Previously generated codes are invalidated. Recovery codes can be used instead of a TOTP code during login.
Login flow with 2FA
Section titled “Login flow with 2FA”When a user with 2FA enabled logs in via POST /connect/token (authorization code flow):
- Server returns a
challenge_token(short-lived JWT, 5 min TTL) - Client sends
POST /connect/tokenwith:grant_type=urn:granit:grant_type:two_factorchallenge_token=...totp_code=123456(or a recovery code)
- Server validates the TOTP code and issues standard access + refresh tokens
This flow is stateless — no server-side sessions or cookies are required.
External logins
Section titled “External logins”External login support is gated behind the OpenIddict.ExternalLogins feature flag
(default: true). Supported providers are configured in
GranitOpenIddictClientOptions.
List linked providers
Section titled “List linked providers”GET /api/account/external-loginsResponse:
[ { "loginProvider": "Google", "providerKey": "1234567890", "providerDisplayName": "Google" }]Challenge (initiate OAuth flow)
Section titled “Challenge (initiate OAuth flow)”POST /api/account/external-logins/challenge/{provider}Validates that the provider is configured in GranitOpenIddictClientOptions.Providers.
Returns 400 if not configured. The actual OAuth redirect is handled by the OpenIddict
client middleware.
OAuth callback
Section titled “OAuth callback”GET /api/account/external-logins/callback?provider=GoogleProcesses the OAuth callback. Behavior depends on
GranitOpenIddictClientOptions.AutoRegisterExternalUsers:
| Scenario | AutoRegister = true | AutoRegister = false |
|---|---|---|
| Existing user with linked provider | Authenticates | Authenticates |
| Existing email, no link | Links + authenticates | Links + authenticates |
| No existing user | Creates + authenticates | 403 Forbidden |
| Email taken by another account | 409 Conflict | 409 Conflict |
Unlink a provider
Section titled “Unlink a provider”DELETE /api/account/external-logins/{provider}Removes the provider link. Returns 400 Bad Request if it is the last login method
and no password is set (the user would be locked out).
Custom claim mapping
Section titled “Custom claim mapping”External provider claims are mapped to user properties via ExternalClaimsMapper.
The default mapper extracts email, given_name, family_name from standard OIDC
claim types. Override by registering a subclass:
public class MyCustomClaimsMapper : ExternalClaimsMapper{ public override ExternalUserProperties MapToUserProperties( ClaimsPrincipal principal, string provider) { var props = base.MapToUserProperties(principal, provider); // Custom mapping logic return props; }}
// Register before OpenIddictservices.AddScoped<ExternalClaimsMapper, MyCustomClaimsMapper>();Passkeys (WebAuthn / FIDO2)
Section titled “Passkeys (WebAuthn / FIDO2)”Passkey endpoints are gated behind the OpenIddict.Passkeys feature flag (default:
false). When enabled, requires GranitPasskeyOptions
configuration. Uses ASP.NET Core Identity’s built-in .NET 10 WebAuthn support.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/account/passkeys | Required | List registered passkeys |
POST | /api/account/passkeys/register/begin | Required | Begin WebAuthn registration ceremony |
POST | /api/account/passkeys/register/complete | Required | Complete registration with attestation |
POST | /api/account/passkeys/assertion/begin | Anonymous | Begin WebAuthn assertion ceremony (login) |
PATCH | /api/account/passkeys/{id} | Required | Rename a passkey |
DELETE | /api/account/passkeys/{id} | Required | Delete a passkey |
Registration flow
Section titled “Registration flow”-
POST /api/account/passkeys/register/beginreturnsPublicKeyCredentialCreationOptionsJSON withmediation: "conditional"for browser-native passkey autofill (Conditional UI). -
POST /api/account/passkeys/register/completewith the browser’sAuthenticatorAttestationResponse:
Request:
{ "credentialJson": "{...WebAuthn attestation response JSON...}", "name": "My YubiKey"}Response (201):
{ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "My YubiKey", "createdAt": "2026-03-21T10:00:00Z", "lastUsedAt": null}Assertion flow (login)
Section titled “Assertion flow (login)”POST /api/account/passkeys/assertion/begin returns PublicKeyCredentialRequestOptions
JSON with empty allowCredentials for Conditional UI autofill. The actual assertion
verification is handled by the urn:granit:grant_type:passkey custom grant at the
/connect/token endpoint.
Management
Section titled “Management”- Rename:
PATCH /api/account/passkeys/{id}with{ "name": "New Name" } - Delete:
DELETE /api/account/passkeys/{id}— returns400if this is the last credential and no password is set on the account (prevents lockout)
Session management
Section titled “Session management”Heartbeat
Section titled “Heartbeat”POST /api/account/session/heartbeatUpdates LastActivityAt in the distributed cache for idle session tracking (key:
session:{userId}:{jti}, TTL: 35 min default). No-op for sessions with
remember_me = true claim.
The frontend should call this endpoint periodically (e.g., every 5 minutes) while
the user is active. When heartbeats stop, the OpenIddictIdleSessionEnforcementJob
background job revokes the refresh token after the configured idle timeout.
Returns 204 No Content.
Back to impersonator
Section titled “Back to impersonator”POST /api/account/session/back-to-impersonatorEnds an impersonation session and returns fresh tokens for the original administrator.
Reads the impersonator_id claim from the current token.
Response (200):
{ "accessToken": "eyJ...", "refreshToken": "CfDJ...", "expiresIn": 3600}Returns 400 Bad Request if the current session is not an impersonation (no
impersonator_id claim present).
Account deletion (GDPR)
Section titled “Account deletion (GDPR)”POST /api/account/deleteImplements GDPR Article 17 (right to erasure). Requires password confirmation to prevent accidental or unauthorized deletion.
Request:
{ "password": "MyCurrentP@ss123"}What happens on deletion:
- Password verification via
IIdentityCredentialVerifier - Soft-delete:
IsDeleted = true,DeletedAt = now,DeletedBy = userId - All active tokens and authorizations revoked
AccountDeletedEtopublished viaIDistributedEventBusfor downstream cleanup- Audit log entry written
Returns 202 Accepted (downstream cleanup is asynchronous via event subscribers).
Returns 400 Bad Request if the password is incorrect.
Validation
Section titled “Validation”All request DTOs have corresponding FluentValidation validators auto-discovered by
GranitValidationModule. Validation is applied automatically via
FluentValidationAutoEndpointFilter because all route groups use MapGranitGroup().
| Validator | Request type |
|---|---|
AccountRegisterRequestValidator | AccountRegisterRequest |
AccountProfileUpdateRequestValidator | AccountProfileUpdateRequest |
AccountPasswordChangeRequestValidator | AccountPasswordChangeRequest |
AccountPasswordResetRequestValidator | AccountPasswordResetRequest |
AccountForgotPasswordRequestValidator | AccountForgotPasswordRequest |
AccountTwoFactorEnableRequestValidator | AccountTwoFactorEnableRequest |
AccountDeleteRequestValidator | AccountDeleteRequest |
PasskeyRegistrationRequestValidator | PasskeyRegistrationRequest |
PasskeyRenameRequestValidator | PasskeyRenameRequest |
AdminUserCreateRequestValidator | AdminUserCreateRequest |
Endpoint route customization
Section titled “Endpoint route customization”Route prefixes and OpenAPI tags are configurable:
app.MapOpenIddictEndpoints(options =>{ options.AccountRoutePrefix = "api/account"; // default options.AdminRoutePrefix = "api/admin"; // default options.AccountTagName = "Account"; // OpenAPI tag options.AdminTagName = "Administration"; // OpenAPI tag});