Skip to content

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:

GroupEndpointsAuth required
Login2Anonymous
Registration3Anonymous / Authenticated
Profile2Authenticated
Password3Mixed
Two-factor setup5Authenticated
External logins4Mixed
Passkeys7Mixed
Session2Authenticated
Deletion1Authenticated
POST /api/account/login

Authenticates 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 OK with { "succeeded": true } on success
  • 200 OK with { "succeeded": false, "requiresTwoFactor": true } when 2FA is enabled — the client must call the two-factor login endpoint next
  • 401 Unauthorized for invalid credentials, unconfirmed email, or locked-out account

Rate-limited via the authentication policy (soft dependency on Granit.RateLimiting).

POST /api/account/login/two-factor

Completes 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 OK with { "succeeded": true } on success (session cookie set)
  • 401 Unauthorized if the code is invalid or the 2FA session expired

Rate-limited via the authentication policy.

Granit implements a multi-layered brute-force protection strategy aligned with OWASP Authentication Cheat Sheet and NIST SP 800-63B recommendations.

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 lockoutDuration
1st (after 5 failed attempts)5 minutes
2nd10 minutes
3rd20 minutes
4th40 minutes
5th1 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.

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.

Granit applies four complementary protections on the login endpoint:

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

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));

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).

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.

The exponential backoff is implemented via GranitUserManager — a custom UserManager<GranitUser> that overrides AccessFailedAsync():

  1. base.AccessFailedAsync() increments the failed count and triggers lockout at the threshold
  2. If lockout was triggered, ConsecutiveLockouts is incremented
  3. The lockout duration is recalculated using the exponential formula
  4. LockoutEnd is overwritten with the new, longer duration

GranitUser stores the ConsecutiveLockouts counter (persisted in the openiddict_users table, defaults to 0).

GET /api/account/config

Public endpoint (anonymous) that exposes runtime feature flags for the frontend. Uses IAsyncModuleConfigProvider to resolve settings from Granit.Settings.

Response:

{
"allowSelfRegistration": false
}
PropertyValue
Default"false" (disabled)
Visible to clientsYes
ProvidersTenant (T), Global (G)

Enable via the Settings admin API or appsettings.json:

{
"Settings": {
"Identity.Local.AllowSelfRegistration": "true"
}
}
POST /api/account/register

Creates 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 Accepted regardless 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
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.

POST /api/account/resend-confirmation-email

Resends the confirmation email to the authenticated user. Always returns 202 Accepted regardless of outcome to prevent email enumeration attacks.

GET /api/account/profile

Returns 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"]
}
PUT /api/account/profile

Updates firstName and lastName. Returns the updated AccountProfileResponse.

Request:

{
"firstName": "Alice",
"lastName": "Smith"
}
EndpointAuthDescription
POST /api/account/change-passwordRequiredValidates current password, sets new one
POST /api/account/forgot-passwordAnonymousAlways returns 202 (prevents enumeration)
POST /api/account/reset-passwordAnonymousValidates token from email, sets new password

Validates the current password before setting the new one.

Request:

{
"currentPassword": "OldP@ss123",
"newPassword": "NewSecureP@ss456"
}
  • 204 No Content on success
  • 400 Bad Request if current password is incorrect

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.

Validates the reset token and sets the new password.

Request:

{
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"token": "CfDJ8...",
"newPassword": "NewSecureP@ss456"
}
  • 204 No Content on success
  • 400 Bad Request if token is invalid or expired

All 2FA endpoints require authentication. The OpenIddict.TwoFactor feature flag (default: true) controls availability at the tenant level.

  1. Get status: GET /api/account/two-factor
{
"isEnabled": false,
"hasAuthenticatorApp": false,
"recoveryCodesLeft": 0
}
  1. 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.).

  1. 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.

  1. 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.

  1. 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.

When a user with 2FA enabled logs in via the headless account API:

  1. POST /api/account/login returns { "requiresTwoFactor": true } and sets the Identity.TwoFactorUserId cookie
  2. Client prompts for TOTP code (or recovery code)
  3. POST /api/account/login/two-factor with 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 login support is gated behind the OpenIddict.ExternalLogins feature flag (default: true). Supported providers are configured in IExternalProviderRegistry.

GET /api/account/external-logins

Response:

[
{
"loginProvider": "Google",
"providerKey": "1234567890",
"providerDisplayName": "Google"
}
]
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.

GET /api/account/external-logins/callback?provider=Google

Processes the OAuth callback. Behavior depends on IExternalProviderRegistry.AutoRegisterExternalUsers:

ScenarioAutoRegister = trueAutoRegister = false
Existing user with linked providerAuthenticatesAuthenticates
Existing email, no linkLinks + authenticatesLinks + authenticates
No existing userCreates + authenticates403 Forbidden
Email taken by another account409 Conflict409 Conflict
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).

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 OpenIddict
services.AddScoped<ExternalClaimsMapper, MyCustomClaimsMapper>();

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.

MethodPathAuthDescription
GET/api/account/passkeysRequiredList registered passkeys
POST/api/account/passkeys/register/beginRequiredBegin WebAuthn registration ceremony
POST/api/account/passkeys/register/completeRequiredComplete registration with attestation
POST/api/account/passkeys/assertion/beginAnonymousBegin WebAuthn assertion ceremony (login)
POST/api/account/passkeys/assertion/completeAnonymousComplete assertion and sign in
PATCH/api/account/passkeys/{id}RequiredRename a passkey
DELETE/api/account/passkeys/{id}RequiredDelete a passkey
  1. POST /api/account/passkeys/register/begin returns PublicKeyCredentialCreationOptions JSON with mediation: "conditional" for browser-native passkey autofill (Conditional UI).

  2. POST /api/account/passkeys/register/complete with the browser’s AuthenticatorAttestationResponse:

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
}

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 OK with { "succeeded": true } on success (session cookie set)
  • 401 Unauthorized if the credential is invalid or no matching user is found

Both assertion endpoints are rate-limited via the authentication policy.

  • Rename: PATCH /api/account/passkeys/{id} with { "name": "New Name" }
  • Delete: DELETE /api/account/passkeys/{id} — returns 400 if this is the last credential and no password is set on the account (prevents lockout)
POST /api/account/session/heartbeat

Updates 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.

POST /api/account/session/back-to-impersonator

Ends 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).

POST /api/account/delete

Implements GDPR Article 17 (right to erasure). Requires password confirmation to prevent accidental or unauthorized deletion.

Request:

{
"password": "MyCurrentP@ss123"
}

What happens on deletion:

  1. Password verification via IIdentityCredentialVerifier
  2. Soft-delete: IsDeleted = true, DeletedAt = now, DeletedBy = userId
  3. Account locked: LockoutEnd = DateTimeOffset.MaxValue
  4. Security stamp invalidated: forces all existing JWT claims validations to fail
  5. All active OpenIddict tokens revoked via IOpenIddictTokenManager (immediate access termination)
  6. AccountDeletedEto published via IDistributedEventBus for downstream cleanup

Returns 202 Accepted (downstream cleanup is asynchronous via event subscribers). Returns 400 Bad Request if the password is incorrect.

All request DTOs have corresponding FluentValidation validators auto-discovered by GranitValidationModule. Validation is applied automatically via FluentValidationAutoEndpointFilter because all route groups use MapGranitGroup().

ValidatorRequest type
AccountRegisterRequestValidatorAccountRegisterRequest
AccountProfileUpdateRequestValidatorAccountProfileUpdateRequest
AccountPasswordChangeRequestValidatorAccountPasswordChangeRequest
AccountPasswordResetRequestValidatorAccountPasswordResetRequest
AccountForgotPasswordRequestValidatorAccountForgotPasswordRequest
AccountTwoFactorEnableRequestValidatorAccountTwoFactorEnableRequest
AccountTwoFactorDisableRequestValidatorAccountTwoFactorDisableRequest
AccountDeleteRequestValidatorAccountDeleteRequest
AccountTwoFactorLoginRequestValidatorAccountTwoFactorLoginRequest
AccountPasskeyLoginRequestValidatorAccountPasskeyLoginRequest
PasskeyRegistrationRequestValidatorPasskeyRegistrationRequest
PasskeyRenameRequestValidatorPasskeyRenameRequest

Route prefixes and OpenAPI tags are configurable:

api.MapGranitAccount(options =>
{
options.AccountRoutePrefix = "account"; // default
options.AdminRoutePrefix = "admin"; // default
});