Skip to content
fullstackhero

Concept

Authentication

JWT bearer with rotating refresh tokens, ASP.NET Identity user store, lockout, email confirmation, per-device session tracking, and the auth-flow rate-limit policy.

views 0 Last updated

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 : IdentityUser and FshRole : IdentityRole.
  • Refresh tokens stored hashed (SHA-256) in FshUser.RefreshToken with RefreshTokenExpiryTime; 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 via PasswordPolicy:*.
  • Email confirmation — new users start with EmailConfirmed = false and cannot mint tokens until the confirm-email flow completes.
  • Two-factor TOTP — opt-in per user; once enrolled, token/issue requires 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 auth policy (10/min per user-or-IP by default — note RateLimitingOptions:Enabled is false in the base config and true in appsettings.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:

Terminal window
openssl rand -base64 32

Store 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 TokenResponse

The access token carries the standard claims plus:

  • sub / email / name — RFC 7519 short forms (legacy ClaimTypes.* equivalents are also emitted)
  • tenant — the resolved tenant at login time
  • fullName, 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:

  1. Hashes the presented refresh token and looks up the matching user; rejects if expired.
  2. Validates the session tied to that refresh-token hash — a revoked session kills the refresh even if the token itself is still valid.
  3. Cross-checks the access token’s subject against the refresh token’s owner.
  4. 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):

VerbRouteWhat
GET/sessions/meThe current user’s sessions
POST/sessions/revoke-allRevoke all of the current user’s sessions
GET/sessionsAll sessions in the tenant (admin)
GET/users/{userId}/sessionsOne user’s sessions (admin)
POST/users/{userId}/sessions/revoke-allAdmin 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 mailed
2. User clicks link → GET /api/v1/identity/confirm-email?userId=…&code=…&tenant=…
3. User can now POST /token/issue → access + refresh

Admins 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-password flow 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.