The Identity module owns authentication. Login goes through POST /api/v1/identity/tokens/generate and returns an access token (default 60 minutes) and a refresh token (default 14 days). Both are signed by Jwt:SigningKey; the refresh token is stored hashed and rotated on every refresh to limit replay damage if it leaks. Both endpoints (and every other auth-flow endpoint) are throttled by the kit’s auth rate-limit policy.
What ships
- JWT bearer via
Microsoft.AspNetCore.Authentication.JwtBearer10.0.8. - ASP.NET Identity on top of
FshUser : IdentityUserandFshRole : IdentityRole. - Refresh tokens stored hashed in
FshUser.RefreshToken; rotated on each/tokens/refresh. - Lockout — 5 failed attempts triggers 15-minute lockout (
IdentityOptionsdefaults). - Password policy — 12-character minimum, history (last 5 blocked), 90-day expiry with 14-day warning, customisable in
PasswordPolicy:*. - Email confirmation — new users start with
EmailConfirmed = falseand cannot mint tokens until theconfirm-emailflow completes. - Two-factor TOTP — opt-in per user; once enrolled,
tokens/generaterequires the TOTP code (see two-factor authentication). - Rate-limited endpoints — login, refresh, confirm-email, forgot-password, reset-password, self-register all carry the
authpolicy.
Configuration
{ "Jwt": { "Issuer": "fullstackhero", "Audience": "fullstackhero-api", "AccessTokenMinutes": 60, "RefreshTokenDays": 14, "SigningKey": "set-via-secrets-manager-or-env-var" }, "PasswordPolicy": { "PasswordHistoryCount": 5, "PasswordExpiryDays": 90, "PasswordExpiryWarningDays": 14, "EnforcePasswordExpiry": true }}For Jwt: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 (Jwt__SigningKey with double underscore).
The token pipeline
POST /tokens/generate └─ rate-limited (auth policy) └─ UserManager.CheckPasswordAsync (with lockout) └─ 2FA check (if enrolled) └─ Email-confirmed check └─ ITokenService.IssueAsync → JWT access + refresh └─ Open a UserSession (per-device tracking) └─ Publish TokenGeneratedIntegrationEvent (outbox) └─ Return TokenResponseThe access token carries the standard claims plus:
sub/uid— the user idtenant— the resolved tenant at login timepermissions— the user’s effective permission set (role + group aggregated)
Permission enforcement on downstream endpoints reads the permissions claim directly — no DB round-trip per request.
Refresh token rotation
POST /tokens/refresh validates the refresh token, mints a fresh access and refresh token, invalidates the old refresh token (clears the stored hash), and returns the new pair. If the same refresh token is presented twice, the second attempt fails with 401 — a signal that either replay-after-rotation happened, or your client is misconfigured (sending the same token in parallel).
public sealed class RefreshTokenCommandHandler(IFshUserManager users, ITokenService tokens) : ICommandHandler<RefreshTokenCommand, RefreshTokenCommandResponse>{ public async ValueTask<RefreshTokenCommandResponse> Handle(RefreshTokenCommand cmd, CancellationToken ct) { var user = await users.FindByValidRefreshTokenAsync(cmd.RefreshToken, ct) ?? throw new UnauthorizedException("Refresh token invalid or expired.");
var pair = await tokens.IssueAsync(user, ct).ConfigureAwait(false); // IssueAsync rotates user.RefreshToken inside the same transaction return new RefreshTokenCommandResponse(pair.AccessToken, pair.RefreshToken, pair.ExpiresInSeconds); }}Email confirmation flow
Newly registered users have EmailConfirmed = false. Token generation rejects them with 401:
1. POST /users/self-register → user created, EmailConfirmed = false, confirm-email link mailed2. User clicks link → POST /users/confirm-email → EmailConfirmed = true3. User can now POST /tokens/generate → access + refreshIn integration tests, the kit uses a helper to flip EmailConfirmed = true directly via UserManager.UpdateAsync (mirroring the pattern in Tests/Users/EmailConfirmationTests.cs).
Lockout and brute-force defence
Lockout is configured by IdentityOptions. The kit’s defaults:
- Lockout after 5 consecutive failed attempts.
- Lockout duration: 15 minutes.
- Lockout applies to login attempts only; successful logins reset the counter.
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. The kit has an
AspNetUserPasskeystable from ASP.NET Identity but no end-to-end passkey-only flow. Passkey support is on the roadmap.
Related
- Authorization — what the token claims gate after authentication.
- Two-factor authentication — TOTP enrolment + verification.
- Impersonation — operator impersonation grants.
- Identity module — the full implementation.
- Rate limiting — the auth-flow throttle.