Identity: JWT, MFA, sessions
Authentication is Genie’s outermost security layer. It issues short-lived RS256 JWT access tokens
backed by refresh tokens, supports cookie sessions, layers on MFA / TOTP, and enforces
single-session eviction through a rotating security stamp. Everything lives behind
AuthController at base route /api/v1/auth.
The login flow
Section titled “The login flow”POST /api/v1/auth/login runs AuthenticationService.AuthenticateAsync:
- Look up the user by username or email. Unknown/deleted users still incur a dummy password-verify
round-trip, so response timing never betrays whether an account exists. Every credential failure
collapses to one generic message (
Invalid username or password.) — the specific reason is logged server-side only. Detailed messages are opt-in viaSecurity:DetailedAuthErrors(non-production). - Web-login gates (cookie logins): individually disabled users and
Security:WebLoginBlockedRolesare rejected. - Lockout: after
Security:MaxInvalidLoginAttemptsthe account is locked — temporarily (Security:UserLockedTimeoutMinutes) or permanently until an admin unlocks. - Password verify via the
IPasswordHasher(a SQL-backed AES decrypt — see Password encryption). - MFA branch (below) if enabled for the user.
- Password expiry (
Security:PasswordExpiryDays) — a stale password forces a change. - On success, mint the token(s) and establish the session.
JWT access tokens (RS256)
Section titled “JWT access tokens (RS256)”JwtService mints RS256-signed JWTs. RS256 is asymmetric on purpose: the issuer holds the private
key, and every validator needs only the public key — a leaked validation key can’t forge tokens.
(The pre-J-1 HS256 shared-secret scheme, where any validator could also mint tokens, was removed.)
The payload is deliberately minimal — sub, name (username), role claims, security_stamp,
and jti. Email, friendly name, and user type are omitted: a JWT is base64-encoded, not
encrypted, so anything in it leaks anywhere the token is logged. The cookie principal carries the
fuller claim set, because ASP.NET Data Protection encrypts cookies.
Tokens are short-lived (JwtSettings:AccessTokenExpiryMinutes); the client refreshes via
POST /api/v1/auth/refresh against a stored refresh token (RefreshTokenStore, cache-backed,
raw tokens never persisted). Access tokens are validated on every request against the live
SecurityStamp — see Sessions.
Signing keys: RSA keypair, DataProtection-encrypted
Section titled “Signing keys: RSA keypair, DataProtection-encrypted”JwtKeyProvider is a singleton that, on first run, generates a fresh 2048-bit RSA keypair and
hands it to the configured IJwtKeyStore to persist. The store is selected via
GenieIdentityOptions.JwtKeyStorage (or auth.UseJwtKeyStorage(...) on the fluent builder):
FileSystem(default) — a per-node file, private half encrypted at rest via DataProtection.CacheStore— the shared cache (Redis in production), so one keypair serves every node behind a load balancer. The private half is DataProtection-encrypted before it is written underauth:jwt:private; the public half underauth:jwt:public. Recommended for multi-node hosts.
Keys persist across app recycles (no TTL) so tokens minted before a restart stay valid. The only events that invalidate live tokens are an explicit key rotation (deleting the stored keys) or a SecurityStamp rotation.
The Data-Protection pairing rule
Section titled “The Data-Protection pairing rule”The persisted private key is ciphertext, not the key itself — it can only be decrypted by an app
holding the same ASP.NET Data-Protection key ring and the same application discriminator that
existed when the blob was written. On boot, JwtKeyProvider loads the blob and calls Unprotect;
if either half of that pair has drifted, decryption throws and the provider fails fast with:
JwtKeyProvider: failed to decrypt the persisted private key from the cache store (key 'auth:jwt:private'). The Data-Protection key set may have changed since this key was written…
Failing fast is deliberate — silently regenerating the keypair would invalidate every other node’s
live tokens. The error therefore always means the same thing: the JWT ciphertext outlived the
Data-Protection keys that encrypted it. Because the CacheStore blob is durable (Redis, no TTL)
while a default DP key ring is not, the common causes are all configuration, not code:
- DP persistence not enabled (
DataProtection:Enabled = false, the default). ASP.NET then falls back to its framework default key ring — per-user%LOCALAPPDATA%on Windows,~/.aspnet/DataProtection-Keyson Linux. Under a systemd service without a writable home, an IIS app pool without a loaded profile, or a container without a persisted volume, that default is ephemeral: a brand-new DP key every restart, so the Redis-persisted JWT key fails to decrypt on every restart. - The content root moved. The framework-default application discriminator is the app’s
content-root path. Running the same binary from a different folder (
dotnet runvs a publish directory, a new deployment path, a rebuilt container image) makes DP treat it as a different application — decryption fails even though the DP key files are unchanged. SettingDataProtection:ApplicationNamepins the discriminator and removes this failure mode. - Two apps sharing one Redis database. The JWT store’s cache keys (
auth:jwt:private/auth:jwt:public) are not namespaced per app. If two Genie hosts point at the same Redis instance and database, they overwrite each other’s keypair, and each then fails to decrypt the other’s ciphertext — a recurring ping-pong where whichever app boots second errors. Give each host its own Redis database (…,defaultDatabase=1) or instance.
Recovery when the error does fire: either restore the matching Data-Protection keys, or delete
the cached auth:jwt:private / auth:jwt:public entries so the keypair regenerates on next boot —
which forces every user to re-authenticate.
MFA / TOTP
Section titled “MFA / TOTP”MFA is evaluated after the password check, but only when the user’s TwoFactorEnabled flag is set
and at least one channel is enabled in Mfa config (Email, SMS, or Google Authenticator) — if
admins disable all channels, MFA is skipped globally so users aren’t stranded.
- TOTP (Google Authenticator) takes priority when enabled (
TotpService). A confirmed enrolment returnsRequiresTotpVerification; an unenrolled user is routed to setup (/api/v1/auth/totp/setup/{userId}→/totp/confirm). Enrolment secrets live inUserTotpSecrets. - Email / SMS OTP (
OtpService) issues a cryptographically-strong 6-digit code (queued through the notification workers), consumed on success and compared in constant time. Non-production builds can use a fixed000000code (Mfa:Email:UseProductionCodes). - Challenge state (
MfaChallengeStore) is short-lived and cache-backed: raw challenge tokens are never stored (only their SHA-256 hash), the absolute expiry travels in the payload so retries can’t slide the window, and attempt/resend ceilings destroy the challenge. - Attempt limiting is enforced by
IOtpVerificationLimiterat the entry points (a tries-then-block lockout), so a wrong code doesn’t burn the OTP itself. - Verify at
/api/v1/auth/mfa/verify, resend at/api/v1/auth/mfa/resend/{userId}. MFA can be reset via/api/v1/auth/reset-mfa/*.
The anonymous surface — login, MFA, refresh, forgot/reset-password — is the only [AllowAnonymous]
opt-in and is rate-limited. reCAPTCHA (RecaptchaVerifier) can gate the public entry points.
Sessions
Section titled “Sessions”- Cookie sign-in issues a ticket stored server-side (
RedisTicketStore), so a per-devicePOST /api/v1/auth/logoutdeletes that ticket and kills any siphoned copy of the cookie while other devices stay signed in. - SecurityStamp: every cookie/JWT carries a stamp claim, re-checked on each request by
UserSessionValidator. Rotating the stamp evicts every session for that user on their next request. - Single-session is the default (
Security:AllowConcurrentSessions = false): a new sign-in rotates the stamp and revokes the user’s other refresh tokens — last login wins. Allowing concurrent sessions just ensures a stamp exists without disturbing other devices. - Sign out everywhere (
/api/v1/auth/logout-everywhere) bumps the stamp and clears the current cookie — used on suspected compromise. Changing or resetting a password also rotates the stamp, so a stolen cookie/JWT is useless afterwards. - Password reset (
PasswordResetService) issues a magic-link token (stored alongside OTPs with a longer expiry); flows are/api/v1/auth/forgot-password,/reset-password/validate,/reset-password, and/change-password. - Impersonation (a separate controller) mints a target-user JWT carrying impersonation markers without rotating the target’s stamp; System users can’t be impersonated, and the impersonation token is not refreshable.
Wiring
Section titled “Wiring”Identity is registered as part of the engine composition (AddGenieApp → ConfigureAuth). Configure
the JWT issuer/audience/expiry under JwtSettings, MFA channels under Mfa, lockout/expiry/history
under Security, and pick the key store via GenieIdentityOptions.JwtKeyStorage. The React client
reads enabled auth features from GET /api/v1/auth/config and adapts its screens automatically.
Related
Section titled “Related”- Password encryption — how the password the login verifies is stored.
- RBAC & permissions — what an authenticated identity is then allowed to do.
- Security model — the full layered picture.