The Identity module owns authentication. Login goes through POST /api/v1/identity/token/issue and returns an access token (45 minutes in the shipped config) and a refresh token (7 days). The access token is signed with JwtOptions:SigningKey; the refresh token is stored as a SHA-256 hash on the user and rotated on every refresh to limit replay damage if it leaks. Both endpoints (and every other auth-flow endpoint) carry the kit’s auth rate-limit policy.
What ships
- JWT bearer via
Microsoft.AspNetCore.Authentication.JwtBearer. - ASP.NET Identity on top of
FshUser : IdentityUserandFshRole : IdentityRole. - Refresh tokens stored hashed (SHA-256) in
FshUser.RefreshTokenwithRefreshTokenExpiryTime; rotated on each/token/refresh. - Per-device sessions — every login opens a
UserSession(IP, user agent, device/browser/OS) keyed to the refresh-token hash; sessions can be listed and revoked, including admin revoke-all. - Lockout — 5 consecutive failed attempts triggers a 15-minute lockout.
- Password rules — 10-character minimum with digit, lowercase, and uppercase required (
IdentityOptions); history, expiry, and warning windows viaPasswordPolicy:*. - Email confirmation — new users start with
EmailConfirmed = falseand cannot mint tokens until the confirm-email flow completes. - Two-factor TOTP — opt-in per user; once enrolled,
token/issuerequires the TOTP code (see two-factor authentication). - Rate-limited endpoints — token issue, refresh, confirm-email, resend-confirmation, forgot-password, reset-password, and self-register all carry the
authpolicy (10/min per user-or-IP by default — noteRateLimitingOptions:Enabledisfalsein the base config andtrueinappsettings.Production.json).
Configuration
{ "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", "SigningKey": "", // set via secrets manager / env var "AccessTokenMinutes": 45, "RefreshTokenDays": 7 }, "PasswordPolicy": { "PasswordHistoryCount": 5, "PasswordExpiryDays": 90, "PasswordExpiryWarningDays": 14, "EnforcePasswordExpiry": true }}For JwtOptions:SigningKey, use 32+ random bytes encoded in base64:
openssl rand -base64 32Store it in your platform’s secrets system (Azure Key Vault, AWS Secrets Manager, GitHub Secrets, Doppler, etc.) and inject via environment variable (JwtOptions__SigningKey with double underscore).
The token pipeline
POST /api/v1/identity/token/issue └─ rate-limited (auth policy) └─ Lockout check, then UserManager.CheckPasswordAsync └─ 2FA check (if enrolled) └─ User active + email-confirmed check; tenant active + validity check └─ ITokenService.IssueAsync → JWT access + refresh pair └─ Store hashed refresh token; open a UserSession (per-device tracking) └─ Security audit (LoginSucceeded / TokenIssued) + outbox integration event └─ Return TokenResponseThe access token carries the standard claims plus:
sub/email/name— RFC 7519 short forms (legacyClaimTypes.*equivalents are also emitted)tenant— the resolved tenant at login timefullName,image_url— display conveniences- role claims — the user’s direct roles plus any roles inherited via groups
Note what’s not there: permissions. Permission checks resolve server-side from a cached per-user permission set — see authorization. That keeps tokens small and lets permission changes apply to live sessions.
Refresh token rotation
POST /api/v1/identity/token/refresh takes the expiring access token plus the refresh token. The handler:
- Hashes the presented refresh token and looks up the matching user; rejects if expired.
- Validates the session tied to that refresh-token hash — a revoked session kills the refresh even if the token itself is still valid.
- Cross-checks the access token’s subject against the refresh token’s owner.
- Issues a fresh access and refresh token, overwrites the stored hash, and re-keys the session to the new refresh-token hash.
Presenting the same refresh token twice fails with 401 on the second attempt — a signal that either replay-after-rotation happened, or your client is misconfigured (sending the same token in parallel). Every rotation is recorded in the security audit log (TokenRevoked: RefreshTokenRotated, then TokenIssued).
Sessions and revocation
Each login creates a UserSession row: refresh-token hash, IP, user agent, parsed device/browser/OS, created/last-activity/expiry timestamps. Endpoints (under /api/v1/identity):
| Verb | Route | What |
|---|---|---|
| GET | /sessions/me | The current user’s sessions |
| POST | /sessions/revoke-all | Revoke all of the current user’s sessions |
| GET | /sessions | All sessions in the tenant (admin) |
| GET | /users/{userId}/sessions | One user’s sessions (admin) |
| POST | /users/{userId}/sessions/revoke-all | Admin kill switch for a user |
Revocation is enforced at refresh time: a revoked session can’t mint new tokens, but an already-issued access token stays valid until it expires (up to AccessTokenMinutes). Keep access tokens short if that window matters to you.
Email confirmation flow
Newly registered users have EmailConfirmed = false. Token issuance rejects them with 401:
1. POST /api/v1/identity/self-register → user created, confirm-email link mailed2. User clicks link → GET /api/v1/identity/confirm-email?userId=…&code=…&tenant=…3. User can now POST /token/issue → access + refreshAdmins can also confirm on a user’s behalf via the admin-confirm-email endpoint.
Lockout and brute-force defence
Lockout is configured in IdentityOptions (in IdentityModule):
- Lockout after 5 consecutive failed password attempts.
- Lockout duration: 15 minutes.
- The lockout check runs before the password check, so an attacker can’t distinguish a locked account from a wrong password.
- A successful login resets the failed-attempt counter.
- A locked account gets HTTP 423 Locked, not a generic 401.
This stacks with the rate-limit auth policy — rate limiting throttles abusive callers regardless of identity; lockout protects individual user accounts even from low-rate but persistent attackers.
What authentication doesn’t do
- Single sign-on (SSO). No OIDC / SAML / OAuth provider integration in v10. JWT bearer is the only token type. SSO is the most-requested roadmap item; if you need it now, layer an external IdP (Keycloak, Azure AD B2C, Auth0) in front and accept the IdP’s JWTs.
- Passwordless / magic links. No built-in passwordless flow. The
forgot-passwordflow uses a token-via-email; you could repurpose it but it’s not a first-class passwordless mode. - WebAuthn / passkeys. No passkey support in v10. Roadmap.
Related
- Authorization — what gets gated after authentication.
- Two-factor authentication — TOTP enrolment + verification.
- Impersonation — operator impersonation grants.
- Identity module — the full implementation.
- Rate limiting — the auth-flow throttle.