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 logins6Mixed
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<LocalIdentity> 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

LocalIdentity 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
}

The master switch for account creation — it governs both the local POST /api/account/register flow and the external-provider flow (auto-registration and profile completion). There is no separate switch for external registration.

PropertyValue
Default"false" (disabled)
Visible to clientsYes
ProvidersTenant (T), Global (G)

The gate separates authentication from creation: when disabled, an external login still authenticates an existing linked account, but a new account is never created. The provider registry’s AutoRegisterExternalUsers flag is subordinate — it can only narrow, never widen, what this setting permits.

Enable via the Settings admin API or appsettings.json:

{
"Settings": {
"Identity.Local.AllowSelfRegistration": "true"
}
}

The role assigned to every newly self-registered user — local and external. Assignment runs through the shared AssignDefaultRoleHandler, which subscribes to UserRegisteredEto (published by both the register endpoint and complete-registration).

PropertyValue
Default"" (empty — opt-in disabled, no role assigned)
Visible to clientsNo
ProvidersTenant (T), Global (G)
{
"Settings": {
"Identity.Local.DefaultUserRole": "Member"
}
}
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.

Publishing UserRegisteredEto is also what provisions the new user’s role: the shared AssignDefaultRoleHandler subscribes to that event and assigns Identity.Local.DefaultUserRole if it is configured. The same event — and therefore the same handler — fires for external sign-ups completed via complete-registration, so local and external users are provisioned symmetrically. See ADR-066.

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 both configured and backed by a registered authentication handler. Returns 200 to confirm availability, 400 if the provider is not configured, or 500 if it is configured but no authentication scheme is registered for it (a host wiring error — the host forgot AddGoogle() / AddMicrosoftAccount()). This endpoint does not redirect; it is a pre-flight check the frontend can call before sending the user to .../start.

GET /api/account/external-logins/challenge/{provider}/start?returnUrl=/dashboard

Issues the real OAuth challenge: validates the provider, then returns a 302 redirect to it. The browser navigates here directly — it is not an XHR endpoint.

An optional site-relative returnUrl is carried through the external ticket so the callback can resume the original OIDC authorization request after sign-in. Only single-leading-slash relative paths are accepted (/dashboard); absolute URLs and protocol-relative (//evil.com) values are dropped to prevent open redirects.

Responses:

  • 302 Found — redirect to the external provider
  • 400 Bad Request — provider not configured or no handler registered
GET /api/account/external-logins/callback

Handles the redirect from the external provider. The provider is resolved from the external authentication ticket, never from the query string (a query parameter is attacker-controlled and could mis-attribute the link). On success the callback establishes the Identity session via SignInManager.SignInAsync — mirroring the local login path — so the OIDC authorization request can resume.

Behaviour is governed by the master Identity.Local.AllowSelfRegistration gate (resolved per-tenant), not by a separate external flag:

ScenarioAllowSelfRegistration = trueAllowSelfRegistration = false
Existing user with linked providerAuthenticatesAuthenticates
Existing email, no linkLinks + authenticatesLinks + authenticates
No existing user, sufficient provider dataCreates + authenticates (UserRegisteredEto published)403 Forbidden
No existing user, insufficient data (e.g. no email)needs-profile-completion — signed token + prefill, no account created403 Forbidden
Email taken by another account409 Conflict409 Conflict
Callback carries no authentication scheme400 Bad Request400 Bad Request

The callback responds in one of two modes:

  • 302 redirect when ExternalLoginCallbackRedirectUrl is configured (see endpoint options). The status — and, for profile completion, the token + non-sensitive prefill, or otherwise the validated returnUrl — is appended as query parameters to the frontend URL.
  • JSON (ExternalLoginCallbackResponse) when no redirect URL is configured (headless / test hosts) or when ?mode=json is set, which forces JSON regardless of configuration.

The JSON body:

{
"status": "completed",
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"isNewUser": true,
"continuationToken": null,
"prefill": null
}

When the provider data was insufficient, status is needs-profile-completion, userId is null, and the response carries the continuation token plus prefill:

{
"status": "needs-profile-completion",
"userId": null,
"isNewUser": false,
"continuationToken": "CfDJ8...",
"prefill": { "email": null, "firstName": "Alex", "lastName": "Doe" }
}
POST /api/account/external-logins/complete-registration

Creates the account when the external provider did not return enough data for a one-click sign-up. The user fills the pre-filled form (typically just the missing email); this endpoint creates the account, links the external login carried by the token, establishes the session, and publishes UserRegisteredEto (which triggers default-role assignment and the welcome notification).

Request:

{
"token": "CfDJ8...",
"email": "alex@example.com",
"firstName": "Alex",
"lastName": "Doe"
}

The provider and providerKey are not accepted from the body — they travel inside the signed token minted by the callback. firstName / lastName are optional and fall back to the provider values.

Responses:

  • 200 OK with { "succeeded": true } — account created, linked, and signed in
  • 400 Bad Request — the token is invalid, tampered with, or expired (restart the sign-in)
  • 403 Forbidden — self-registration is disabled, or the token does not match the current tenant
  • 409 Conflict — the email is already taken
  • 422 Validation Problem — the email does not match the provider-verified one, or the create/link otherwise failed

When the provider supplied a verified email, that email is authoritative — the submitted email must match it (mismatch → 422). When the provider gave no email, the entered email is unverified and a confirmation email is sent. A failed login-link rolls back the just-created account so a half-finished completion never leaves an orphan.

Rate-limited via the authentication policy.

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
// Absolute frontend URL the external-login callback redirects to after
// processing. When set, the callback responds 302 and appends `status`
// (plus `token` + prefill on profile completion, or `returnUrl` otherwise).
// When null (default), the callback returns JSON — `?mode=json` forces JSON
// regardless.
options.ExternalLoginCallbackRedirectUrl = "https://app.example.com/auth/callback";
});