Account Self-Service API
The account self-service API lives in Granit.Identity.Local.Endpoints — a
provider-agnostic module shared between OpenIddict and any future auth provider
(e.g. Duende Identity Server). Endpoints are available at /api/account
(configurable via AccountEndpointsOptions.AccountRoutePrefix). All endpoints
use MapGranitGroup() for automatic FluentValidation. Register them on your
versioned API route group:
Endpoint summary
Section titled “Endpoint summary”| Group | Endpoints | Auth required |
|---|---|---|
| Login | 2 | Anonymous |
| Registration | 3 | Anonymous / Authenticated |
| Profile | 2 | Authenticated |
| Password | 3 | Mixed |
| Two-factor setup | 5 | Authenticated |
| External logins | 4 | Mixed |
| Passkeys | 7 | Mixed |
| Session | 2 | Authenticated |
| Deletion | 1 | Authenticated |
Password login
Section titled “Password login”POST /api/account/loginAuthenticates via ASP.NET Core Identity and sets the session cookie. Designed for
headless/BFF architectures where the SPA handles the login UI and redirects back
to /connect/authorize after successful authentication.
Request:
{ "login": "alice@example.com", "password": "MyStr0ng!Pass"}Responses:
200 OKwith{ "succeeded": true }on success200 OKwith{ "succeeded": false, "requiresTwoFactor": true }when 2FA is enabled — the client must call the two-factor login endpoint next401 Unauthorizedfor invalid credentials, unconfirmed email, or locked-out account
Rate-limited via the authentication policy (soft dependency on
Granit.RateLimiting).
Two-factor login
Section titled “Two-factor login”POST /api/account/login/two-factorCompletes the 2FA challenge after a successful password login returned
requiresTwoFactor: true. The Identity.TwoFactorUserId cookie (set
automatically by the initial login) identifies the user.
Request (TOTP code):
{ "code": "123456"}Request (recovery code):
{ "code": "ABCD-1234", "useRecoveryCode": true}Spaces and dashes are automatically stripped from the code.
Responses:
200 OKwith{ "succeeded": true }on success (session cookie set)401 Unauthorizedif the code is invalid or the 2FA session expired
Rate-limited via the authentication policy.
Account lockout
Section titled “Account lockout”Granit implements a multi-layered brute-force protection strategy aligned with OWASP Authentication Cheat Sheet and NIST SP 800-63B recommendations.
Exponential backoff
Section titled “Exponential backoff”Instead of a fixed lockout duration, Granit uses exponential backoff — each consecutive lockout doubles the previous duration, capped at a configurable maximum. This prevents both brute-force attacks and denial-of-service via intentional lockout.
| Consecutive lockout | Duration |
|---|---|
| 1st (after 5 failed attempts) | 5 minutes |
| 2nd | 10 minutes |
| 3rd | 20 minutes |
| 4th | 40 minutes |
| 5th | 1 hour 20 minutes |
| 6th+ | 2 hours (cap) |
Formula: min(BaseDuration × 2^(n-1), MaxDuration) where n is the consecutive
lockout count.
Configuration (appsettings.json):
{ "Identity": { "Lockout": { "MaxFailedAccessAttempts": 5, "BaseLockoutDuration": "00:05:00", "MaxLockoutDuration": "02:00:00", "ExponentialBase": 2.0 } }}The GranitLockoutOptions class is bound to Identity:Lockout. All values have
sensible defaults — zero configuration is needed for most deployments.
Automatic reset
Section titled “Automatic reset”The exponential counter (ConsecutiveLockouts) resets to zero when:
- Successful login — the legitimate user logs in normally
- Password reset — the user clicks the reset link in the lockout email
- Admin unlock — an administrator re-enables the account
This ensures a legitimate user who was targeted by an attacker is not permanently penalized after regaining access.
Security measures
Section titled “Security measures”Granit applies four complementary protections on the login endpoint:
1. Anti-enumeration (generic 401)
Section titled “1. Anti-enumeration (generic 401)”The login endpoint never reveals whether an account exists. All failure
scenarios return the same 401 Unauthorized with "Invalid credentials.":
- Unknown email → 401
- Wrong password → 401
- Locked-out account → 401
- Unconfirmed email → 401
2. Timing attack prevention (dummy hash)
Section titled “2. Timing attack prevention (dummy hash)”When the user is not found, the endpoint performs a dummy password hash to ensure consistent response time (~300ms) regardless of account existence. Without this, an attacker could distinguish existing accounts by measuring response latency (10ms for missing accounts vs. 300ms for BCrypt/Argon2 verification).
// A random password is verified against a pre-computed hash each time,// adding natural timing jitter to prevent fingerprinting.hasher.VerifyHashedPassword(null!, DummyPasswordHash, RandomNumberGenerator.GetHexString(32));3. Rate limiting
Section titled “3. Rate limiting”All authentication endpoints use the authentication rate limiting policy
(via Granit.RateLimiting), which throttles requests per tenant. This provides
IP-level protection when combined with an upstream WAF (Cloudflare, Nginx).
4. Lockout email with self-service unlock
Section titled “4. Lockout email with self-service unlock”When a lockout is triggered, an AccountLockedEto integration event is published.
The notification handler sends an email containing:
- The number of failed attempts
- The lockout expiry date (formatted per the user’s locale)
- A “Reset my password” button with a one-time token
Clicking the reset link immediately unlocks the account — the user never needs to wait for the lockout to expire or contact support.
Implementation details
Section titled “Implementation details”The exponential backoff is implemented via GranitUserManager — a custom
UserManager<GranitUser> that overrides AccessFailedAsync():
base.AccessFailedAsync()increments the failed count and triggers lockout at the threshold- If lockout was triggered,
ConsecutiveLockoutsis incremented - The lockout duration is recalculated using the exponential formula
LockoutEndis overwritten with the new, longer duration
GranitUser stores the ConsecutiveLockouts counter (persisted in the
openiddict_users table, defaults to 0).
Module configuration
Section titled “Module configuration”GET /api/account/configPublic endpoint (anonymous) that exposes runtime feature flags for the frontend.
Uses IAsyncModuleConfigProvider to resolve settings from Granit.Settings.
Response:
{ "allowSelfRegistration": false}Identity.Local.AllowSelfRegistration
Section titled “Identity.Local.AllowSelfRegistration”| Property | Value |
|---|---|
| Default | "false" (disabled) |
| Visible to clients | Yes |
| Providers | Tenant (T), Global (G) |
Enable via the Settings admin API or appsettings.json:
{ "Settings": { "Identity.Local.AllowSelfRegistration": "true" }}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.identity.local.users.registered metric.
Request:
{ "email": "alice@example.com", "password": "MyStr0ng!Pass", "firstName": "Alice", "lastName": "Doe"}Responses:
403 Forbidden— self-registration is disabled for the current tenant- Always returns
202 Acceptedregardless of whether the email is new or already taken, to prevent user enumeration attacks. If the email already exists, the request is silently ignored (the existing user is not affected). 422 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", "email": "alice@example.com", "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:
{ "email": "alice@example.com"}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
Requires password confirmation as step-up authentication (OWASP ASVS V2.8.1). Invalidates the security stamp, forcing re-authentication on all existing sessions.
Request:
{ "password": "MyCurrentP@ss123"}Returns 204 No Content on success. Returns 400 Bad Request if the password is
incorrect.
- Regenerate recovery codes:
POST /api/account/two-factor/recovery-codes
Generates 10 new single-use recovery codes. Previously generated codes are invalidated. Requires password confirmation as step-up authentication. Recovery codes can be used instead of a TOTP code during login.
Request:
{ "password": "MyCurrentP@ss123"}Returns 400 Bad Request if the password is incorrect.
Login flow with 2FA
Section titled “Login flow with 2FA”When a user with 2FA enabled logs in via the headless account API:
POST /api/account/loginreturns{ "requiresTwoFactor": true }and sets theIdentity.TwoFactorUserIdcookie- Client prompts for TOTP code (or recovery code)
POST /api/account/login/two-factorwith the code — sets the session cookie on success
For the OIDC token endpoint (POST /connect/token), the urn:granit:grant_type:two_factor
custom grant type is also supported for stateless flows.
External logins
Section titled “External logins”External login support is gated behind the OpenIddict.ExternalLogins feature flag
(default: true). Supported providers are configured in
IExternalProviderRegistry.
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 IExternalProviderRegistry.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
IExternalProviderRegistry.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) |
POST | /api/account/passkeys/assertion/complete | Anonymous | Complete assertion and sign in |
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.
POST /api/account/passkeys/assertion/complete validates the AuthenticatorAssertionResponse
from the browser, resolves the user, and signs them in via ASP.NET Core Identity.
Request:
{ "credentialJson": "{...WebAuthn assertion response JSON...}"}Responses:
200 OKwith{ "succeeded": true }on success (session cookie set)401 Unauthorizedif the credential is invalid or no matching user is found
Both assertion endpoints are rate-limited via the authentication policy.
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 - Account locked:
LockoutEnd = DateTimeOffset.MaxValue - Security stamp invalidated: forces all existing JWT claims validations to fail
- All active OpenIddict tokens revoked via
IOpenIddictTokenManager(immediate access termination) AccountDeletedEtopublished viaIDistributedEventBusfor downstream cleanup
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 |
AccountTwoFactorDisableRequestValidator | AccountTwoFactorDisableRequest |
AccountDeleteRequestValidator | AccountDeleteRequest |
AccountTwoFactorLoginRequestValidator | AccountTwoFactorLoginRequest |
AccountPasskeyLoginRequestValidator | AccountPasskeyLoginRequest |
PasskeyRegistrationRequestValidator | PasskeyRegistrationRequest |
PasskeyRenameRequestValidator | PasskeyRenameRequest |
Endpoint route customization
Section titled “Endpoint route customization”Route prefixes and OpenAPI tags are configurable:
api.MapGranitAccount(options =>{ options.AccountRoutePrefix = "account"; // default options.AdminRoutePrefix = "admin"; // default});