Appearance
Migrating from v4 to v5
GrydAuth v5.0.0 is a security-hardening release. It contains breaking changes that require configuration and database updates before an existing v4 application can boot.
This guide covers everything that changes for a project upgrading from 4.0.2 to 5.0.0. The changes come from four security epics: Sprint 1 – Foundational (systemic risk), Sprint 2 – Revocation robustness & brute-force resilience, Sprint 3 – Hardening & maturity, and the new Multi-Factor Authentication (MFA) capability.
Breaking changes — action required
v5 will not start with a v4 configuration, and some changes require user action after the upgrade. The most impactful changes are:
- JWT signing moves from symmetric (HS256) to asymmetric (RS256/ES256).
JwtSettings:SecretKeyis gone and the app fails fast at boot if the new keys are missing or weak. - Password hashing moves to Argon2id with no legacy verifier. Password hashes created by v4 can no longer be verified — existing users must reset their password after the upgrade (see section 10).
- Client-side RSA password encryption is removed. Clients must send passwords directly over TLS and stop calling
/api/v1/auth/public-key(see section 11).
Breaking changes at a glance
| # | Area | v4 (4.0.2) | v5 (5.0.0) | Impact |
|---|---|---|---|---|
| 1 | JWT signing | HS256 symmetric, single shared SecretKey | RS256/ES256 asymmetric, private key in Auth only, public keys via JWKS | High — config + every token consumer |
| 2 | Access-token TTL | up to 60 min (free-form) | 1–15 min, default 10, enforced at boot | Medium — config |
| 3 | Cache failure policy | fail-open (revoked tokens re-accepted on cache outage) | fail-secure by default | Medium — behavioral |
| 4 | CORS | falls back to AllowAnyOrigin when unconfigured | secure by default, denies when no origins set outside Development | Medium — config |
| 5 | Password-reset token | stored as plaintext in DB | stored as SHA-256 hash only | Medium — DB migration, legacy tokens invalidated |
| 6 | Boot-time validation | partial (?? throw on null only) | JwtSettings validated on start (strength, algorithm, TTL) | Medium — fail-fast |
| 7 | Rate limiting | none (lockout + Zero Trust only) | native per-IP and per-account limits on sensitive endpoints, on by default | Medium — new 429 responses, config |
| 8 | Refresh in degraded mode | rotated tokens even when reuse detection was down (fail-open) | rotation denied when reuse detection is unavailable (fail-secure) | Medium — behavioral |
| 9 | Token revocation identity | mixed: jti and a truncated token hash; cached tokens could skip the blacklist | full jti everywhere; cached principals always re-checked | Low — behavioral hardening |
| 10 | Password hashing | previous scheme, verified in place | Argon2id (PHC), no legacy verifier | High — existing users must reset password |
| 11 | Password transport | client-side RSA encryption + /api/v1/auth/public-key | plaintext field over TLS only; endpoint & flags removed | High — client change |
| 12 | Token type claim | type claim, includes a tenant type, implicit access fallback | single canonical token_type claim; per-endpoint allowlist | Medium — token consumers |
| 13 | Client IP behind proxy | trusts request-provided IP / X-Forwarded-For | resolves IP only from the connection; ForwardedHeaders for known proxies | Medium — config |
| 14 | HTTPS/HSTS | not enforced by templates | HTTPS redirection + HSTS enabled outside Development | Medium — infra |
| 15 | Sensitive logging | free-form; secrets could reach logs | masking policy required (.Destructure.ByMaskingProperties()); JTI logged as hash | Medium — consumer logging config |
| 16 | available_tenants | embedded as a JWT claim | returned in the response body (AuthenticationResult) | Low — clients read from body |
| 17 | MFA (TOTP, recovery codes, step-up) | none | opt-in per tenant; step-up enforcement, mfa_pending token | Opt-in — new feature, config |
1. JWT signing: HS256 → RS256/ES256 + JWKS
What changed and why
In v4 every service that validated a token also held the shared SecretKey, so any consumer could forge tokens for any user, tenant, role or permission. A leak in a single consumer compromised the whole ecosystem.
In v5 the issuer (GrydAuth) is separated from the verifiers (consumers):
- GrydAuth holds the private key and signs with RS256 (default) or ES256.
- Consumers receive only the public key, fetched from a new JWKS endpoint.
- Validation resolves the key by the token's
kidheader and pinsValidAlgorithms.HS256,alg:noneand forged tokens are rejected with 401.
New JWKS endpoint
http
GET /.well-known/jwks.jsonReturns the active public key(s) in standard JWK format (kty, use, kid, alg, plus n/e for RSA or crv/x/y for EC). The endpoint is anonymous and safe to expose — it never contains private key material. It returns two keys during rotation (see below).
Configuration changes
Removed:
jsonc
// ❌ v4 — no longer supported. Boot fails if present/expected.
"JwtSettings": {
"SecretKey": "a-shared-symmetric-secret"
}Added:
jsonc
// ✅ v5
"JwtSettings": {
"Algorithm": "RS256", // RS256 (default) or ES256
"ActiveKeyId": "prod-key-2026-01", // kid of the signing key (required)
"PrivateKeyPem": "${JWT_PRIVATE_KEY_PEM}", // PEM, from a secret manager — NEVER in VCS
"Issuer": "your-app",
"Audience": "your-app-users",
"ExpirationMinutes": 10 // must be 1–15 (see section 2)
}PEM input formats
PrivateKeyPem accepts a raw PEM, a PEM with escaped \n newlines, or a Base64-encoded PEM. Supply it from an environment variable / secret manager (e.g. JwtSettings__PrivateKeyPem), never from a committed file.
Generating keys
RSA (RS256):
bash
# Private key (keep in Auth's secret manager only)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
# Public key (for reference / offline consumers)
openssl rsa -in jwt-private.pem -pubout -out jwt-public.pemEC (ES256):
bash
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out jwt-private.pem
openssl ec -in jwt-private.pem -pubout -out jwt-public.pemImpact on token consumers
Any service that validated GrydAuth tokens with the shared secret must change:
- Remove the shared
SecretKeyfrom its configuration and packages. - Validate via JWKS / public key only. Pin the algorithm to
RS256/ES256. - Confirm the consumer package contains no private key.
Key rotation
Rotation is supported with key overlap so tokens signed by the previous key keep validating while the new key becomes active:
jsonc
"JwtSettings": {
"ActiveKeyId": "prod-key-2026-02", // new signing key
"PrivateKeyPem": "${JWT_PRIVATE_KEY_PEM_2}",
"NextKeyId": "prod-key-2026-01", // previous key, still trusted for validation
"NextPrivateKeyPem": "${JWT_PRIVATE_KEY_PEM_1}"
}Additional public-only validation keys may also be listed:
jsonc
"JwtSettings": {
"ValidationKeys": [
{ "KeyId": "prod-key-2025-12", "PublicKeyPem": "${OLD_PUBLIC_KEY_PEM}" }
]
}During rotation /.well-known/jwks.json publishes both keys; once all old tokens have expired, remove the previous key.
2. Access-token lifetime capped at 15 minutes
JwtSettings:ExpirationMinutes is now validated at boot and must be between 1 and 15 (default 10). Access tokens are short-lived snapshots of authorization; use refresh tokens for session continuity.
jsonc
"JwtSettings": {
"ExpirationMinutes": 10 // ❌ values like 60 now fail at startup
}If your v4 config used 30 or 60 minutes, lower it to ≤15 and rely on refresh-token rotation.
3. Cache failure is now fail-secure
In v4, when the security cache was unavailable the user-blacklist check (Layer 3) returned success (fail-open), so revoked/logged-out tokens could be re-accepted during a cache outage. v5 makes this fail-secure: a cache failure causes the token to be rejected, consistent with the token-version middleware.
This is governed by a single configurable policy:
jsonc
"GrydAuth": {
"TokenValidation": {
"CacheFailureMode": "FailSecure" // default. "FailOpen" restores legacy behavior (not recommended)
}
}Operational note
If your Redis/cache layer is unstable, a v5 upgrade may surface as 401s during outages (previously masked). Fix cache availability rather than switching to FailOpen.
4. Secure CORS by default
v4 templates fell back to AllowAnyOrigin when no origins were configured. v5 is secure by default: outside Development, an empty origin list denies cross-origin requests and logs a configuration error at startup. AllowCredentials is only enabled with explicit origins.
Configure allowed origins explicitly and register CORS via AddGrydAuthCors:
jsonc
"GrydAuth": {
"Cors": {
"AllowedOrigins": [ "https://app.yourcompany.com", "https://admin.yourcompany.com" ]
}
}csharp
builder.Services.AddGrydAuthCors(builder.Configuration);
// ...
app.UseCors("GrydAuthCors");The legacy Cors:AllowedUrls (comma-separated) is still read as a fallback. In Development, common localhost origins are allowed automatically when nothing is configured.
5. Password-reset tokens stored as hash
What changed
v4 stored the 256-bit reset token in the database in plaintext, so any database read (SQLi, backup, replica, insider) yielded usable tokens. v5 stores only the SHA-256 hash; the plaintext value exists only in the emailed link.
- Entity column
Token→TokenHash(PasswordResetToken.Create(...)hashes internally). - Repository lookup
GetValidTokenAsync(token)→GetValidByTokenHashAsync(tokenHash). - Previous tokens are invalidated when a new one is generated and when a reset completes.
- The token (or any prefix) is never logged in production.
The public API is unchanged — POST /api/v1/auth/reset-password still receives the plaintext token from the email link; hashing happens server-side.
Database migration
Apply the EF migration StorePasswordResetTokenHash:
bash
dotnet ef database update --project src/Modules/Auth/GrydAuth.InfrastructureIt renames the column, adds a unique index on TokenHash, and invalidates all existing reset tokens (they are ephemeral — 60 min — so they are soft-deleted rather than converted). Users with a pending v4 reset link must request a new one after the upgrade.
6. Boot-time configuration validation
JwtSettings is now validated with ValidateOnStart(). The application fails fast if:
- the algorithm is not asymmetric (
RS256/ES256); ActiveKeyIdorPrivateKeyPemis missing for asymmetric signing;PrivateKeyPemis a placeholder (${...},__,CHANGE_ME,PLACEHOLDER) that was not resolved from a real secret source;IssuerorAudienceis empty;ExpirationMinutesis outside 1–15.
Real keys and secrets have also been removed from the repository (templates and test settings use placeholders / User Secrets). Rotate any secret that was previously committed.
7. Native rate limiting (on by default)
v4 relied only on account lockout and Zero Trust scoring, leaving room for distributed credential stuffing, lockout-based DoS against a victim, and password-reset flooding. v5 adds native ASP.NET Core rate limiting, enabled by default, registered automatically by AddGrydAuth(configuration) / UseGrydAuth().
Sensitive endpoints are limited per IP and per normalized account:
| Endpoint | Policy | Default limit |
|---|---|---|
POST /api/v1/auth/login | auth-account | 10/min per account, 60/min per IP |
POST /api/v1/auth/refresh | auth-account | 10/min per account, 60/min per IP |
POST /api/v1/auth/request-password-reset | password-reset | 3/hour per email, 10/hour per IP |
POST /api/v1/auth/reset-password | password-reset | 3/hour per email, 10/hour per IP |
Exceeding a limit returns 429 Too Many Requests with a Retry-After header and the standard Gryd ProblemDetails body (errorCode: AUTH_RATE_LIMIT_EXCEEDED). Clients must handle 429 and back off.
jsonc
"GrydAuth": {
"Security": {
"RateLimiting": {
"Enabled": true,
"AuthIpPermitLimit": 60,
"AuthIpWindowSeconds": 60,
"AuthAccountPermitLimit": 10,
"AuthAccountWindowSeconds": 60,
"MaxRequestsPerIpPerHour": 10,
"MaxRequestsPerEmailPerHour": 3,
"PasswordResetWindowSeconds": 3600,
"QueueLimit": 0
}
}
}Client IP behind a proxy
Per-IP partitioning uses the real client IP. Behind a load balancer / reverse proxy, configure ForwardedHeaders for known proxies only so the app receives the true IP and does not trust arbitrary X-Forwarded-For values. Treat application rate limiting as defense in depth alongside a WAF / API gateway at the edge.
8. Refresh tokens fail-secure in degraded mode
In v4, when the reuse-detection store (Redis) was unavailable, RotateRefreshTokenAsync fell back to Zero Trust scoring and could still issue a new 7-day refresh token below a risk threshold — defeating single-use reuse detection during the outage.
In v5 this is fail-secure: when reuse detection is unavailable, refresh-token rotation is denied by default and the user must re-authenticate. The policy is pluggable via IDegradedModeRefreshPolicy (default DenyAllDegradedModeRefreshPolicy). Any alternative that issues a token in degraded mode must require MFA step-up, is capped to a very short TTL, and sits behind a feature flag that is off by default. Denials are recorded as security events.
Client impact
During a cache/Redis outage, refresh calls may fail (forcing login) instead of silently rotating. This is intended. Ensure clients handle a failed refresh by redirecting to authentication.
9. Revocation hardened on the full jti
v4 mixed two revocation identities — the token jti and a 16-char truncated SHA-256 hash — and the cache path rebuilt the principal without the jti, so tokens served from cache could skip the blacklist check (a revocation bypass).
v5 makes the full jti the single revocation identity across all paths, never a truncated hash for security decisions. Cached token data now carries jti, iat and type, so every validated principal (cache or not) re-runs the blacklist check. This is internal hardening — no configuration change — but revocation/logout now reliably blocks cached tokens.
10. Password hashing → Argon2id (no legacy verifier)
What changed and why
v5 hashes passwords with Argon2id (the PHC winner, memory-hard) via the Geralt library (4.3.0). This is a breaking change with no legacy fallback: the v4 hash format is not verifiable in v5, so stored password hashes from v4 cannot be validated after the upgrade.
Existing users must reset their password
Because there is no legacy verifier, every user whose password was hashed in v4 must go through password reset (POST /api/v1/auth/request-password-reset) after upgrading. Plan a communication / forced-reset campaign as part of the cutover. The seed/admin password is re-hashed automatically by the UpgradeSeedPasswordToArgon2id migration.
Argon2id parameters are configurable and re-hashing is transparent: when a user authenticates successfully and their stored hash used weaker parameters than the current configuration, the hash is silently upgraded (NeedsRehash).
Configuration
jsonc
"GrydAuth": {
"PasswordHashing": {
"MemorySizeKiB": 19456, // ≥ 19456 (≈19 MiB)
"Iterations": 2, // ≥ 2
"Parallelism": 1, // must be 1 (Geralt constraint)
"MaxConcurrency": 4 // ≥ 1 — bounds simultaneous hash operations (DoS guard)
}
}Bound to GrydAuth:PasswordHashing and validated at boot. Tune MemorySizeKiB/Iterations upward for your hardware; raise MaxConcurrency only if you have the memory headroom (MemorySizeKiB × MaxConcurrency is the worst-case memory footprint).
Database migration
The UpgradeSeedPasswordToArgon2id migration is included; apply it with the others (see the end of this guide).
11. Client-side RSA password encryption removed
What changed and why
v4 offered optional RSA encryption of the password in the browser before sending it. Over modern TLS this added no security while adding key-management and downgrade risk. v5 removes it entirely (no retro-compatibility) and relies on TLS for confidentiality in transit.
Removed:
- Endpoint
GET /api/v1/auth/public-key— gone. - Request fields
isPasswordEncrypted,isCurrentPasswordEncrypted,isNewPasswordEncrypted— removed from login, user creation, password reset, password change, and first-login completion.
Clients must send the raw password fields directly over HTTPS and drop all RSA logic.
HTTPS/HSTS now enforced
Framework-generated apps now enable HTTPS redirection and HSTS outside Development, and JWT metadata retrieval keeps RequireHttpsMetadata on. Terminate TLS 1.2+ at the app or a trusted ingress/load balancer and redirect HTTP→HTTPS. See the reference note password transport breaking change for the full rationale.
12. Canonical token_type claim
v4 used a type claim (with a tenant token type and an implicit access fallback). v5 standardises on a single canonical token_type claim and validates the token type per endpoint:
| Token type value | Used for |
|---|---|
access | normal authenticated API calls |
refresh | refresh endpoint only |
global | pre-tenant-selection (also accepted on switch-tenant) |
mfa_pending | MFA challenge endpoints only (see MFA below) |
mfa_enrollment | TOTP enrollment endpoints only (MFA onboarding bootstrap) |
Endpoints enforce an allowlist: challenge endpoints accept only mfa_pending; the TOTP enrollment endpoints accept { access, mfa_enrollment }; switch-tenant accepts { global, access }; everything else accepts only access. A missing, duplicated, or unexpected token_type is rejected with 401.
Token consumers
If a downstream service reads the token type, switch from the type claim to token_type, and stop relying on the removed tenant type. Use each token only on the flow it was minted for.
Algorithm pinning (from section 1) is also tightened here: ValidAlgorithms is derived from the signing-key provider and deduplicated — RSA keys accept only RS256, EC keys only ES256, with no symmetric fallback.
13. Trusted client IP via ForwardedHeaders
Rate limiting, auditing and Zero Trust all depend on the real client IP. v5 resolves the IP only from HttpContext.Connection.RemoteIpAddress — no component reads X-Forwarded-For or X-Real-IP directly. Behind a proxy/load balancer you must configure ForwardedHeaders for known proxies only and call UseForwardedHeaders() before authentication and UseGrydAuth().
jsonc
"GrydAuth": {
"ForwardedHeaders": {
"ForwardLimit": 1, // number of proxies to unwrap
"ForwardProto": true, // honour X-Forwarded-Proto
"KnownProxies": [ "10.0.0.4" ], // your ingress/LB IPs — replace 127.0.0.1/::1 defaults
"KnownNetworks": [ "10.0.0.0/24" ] // CID(s) of trusted proxy networks
}
}WARNING
Leaving KnownProxies/KnownNetworks empty (or at the localhost defaults) in production means the app sees the proxy's IP, not the client's — collapsing per-IP rate limiting. Set them to your real edge before go-live.
14. Sensitive-data logging masking
v5 ships a masking policy that redacts credentials, tokens, Authorization values, claim values, raw PII (email/CPF/CNPJ/phone/name/IP/device fingerprint) and Redis keys/values from logs; jti is logged/audited only as a hash.
Consumer logging config
Applications must include .Destructure.ByMaskingProperties() in their Serilog configuration for the policy to take effect. Add it to every sink/logger you configure.
csharp
Log.Logger = new LoggerConfiguration()
.Destructure.ByMaskingProperties() // required in v5
// ... your sinks
.CreateLogger();15. available_tenants moved out of the JWT
v4 embedded the user's tenant list as an available_tenants claim, bloating the token and leaking the tenant topology into anything that could read the JWT. v5 removes the claim and returns the list in the response body instead, as AuthenticationResult.AvailableTenants (alongside RequiresTenantSelection, CurrentTenant, IsGlobal).
Clients
Read the available-tenant list from the login / refresh / switch-tenant response body, not from the decoded token.
16. Token-version revocation resilience (internal)
Revocation by token_version (short-TTL access tokens invalidated on permission change, logout, etc.) gains a resilient lookup path: Redis → database fallback, single-flight coalescing, and a circuit breaker around the cache. This is internal hardening with no configuration to change; the only related knob is the fail-secure cache policy from section 3 (GrydAuth:TokenValidation:CacheFailureMode, default FailSecure). Expect token validation to stay correct (fail-secure) during a cache outage rather than silently accepting revoked tokens.
17. Multi-Factor Authentication (MFA) — new, opt-in
v5 introduces MFA: TOTP (RFC 6238) authenticator apps, single-use recovery codes, and step-up enforcement driven by a per-tenant policy. It is opt-in — an existing v4 deployment keeps working with MFA effectively disabled until you enable a policy — so this is not a breaking change, but it does add configuration and new endpoints. Full behaviour and API are documented on the MFA feature page; this section is the migration/enablement summary.
Required configuration to enable MFA
At-rest encryption of factor secrets is mandatory (validated at boot when a policy can require MFA). Provide a 32-byte Base64URL key:
jsonc
"GrydAuth": {
"Mfa": {
"SecretProtection": {
"ActiveKeyId": "mfa-key-2026-01",
"Keys": { "mfa-key-2026-01": "${MFA_SECRET_KEY_BASE64URL}" } // 32 bytes, Base64URL
},
"Totp": {
"Issuer": "Gryd.IO", // shown in the authenticator app
"Digits": 6, // 6 or 8
"PeriodSeconds": 30, // 15–120
"WindowSteps": 1, // 0–2 — clock-skew tolerance & anti-replay window
"Algorithm": "SHA1", // SHA1 | SHA256 | SHA512
"MaxFailedAttempts": 5, // 3–10 before lockout
"LockoutDuration": "00:15:00"
}
},
"MfaManagement": {
"RecentStepUpMinutes": 10 // 1–60 — freshness window for sensitive MFA operations
},
"MfaPolicy": {
"DefaultMode": "RiskBased", // Always | RiskBased | Disabled
"CacheDurationMinutes": 5
}
}Generate the secret-protection key, for example:
bash
openssl rand 32 | basenc --base64url | tr -d '='Enabling MFA per tenant
The effective mode is resolved per tenant. Set it via the admin API:
http
PUT /api/v1/admin/tenants/{tenantId}/mfa-policy
Content-Type: application/json
{ "mode": "Always" } // Always | RiskBased | DisabledDisabled keeps v4 behaviour for that tenant; RiskBased (the default) requires MFA only when Zero Trust flags the sign-in; Always requires MFA on every login.
What clients must implement
When MFA is required and the user has a usable factor, login returns an AuthenticationResult with token_type = mfa_pending (and no refresh token) instead of full tokens. Clients then:
POST /api/v1/mfa/challenge/begin(with themfa_pendingtoken) to list available factors.POST /api/v1/mfa/challenge/verifywith{ factorType, code }(a TOTP code or a recovery code) to receive the fullAuthenticationResult.
Two more signals complete the flows:
- Onboarding bootstrap — when MFA is required but the user has no usable factor, login returns
tokenType = "MfaEnrollment"with anmfa_enrollmenttoken valid only on the TOTP enrollment endpoints; confirming the first factor elevates the session to full tokens. - In-session step-up — actions guarded by
RequireRecentMfafail with 403 anderrorCode = MFA_STEP_UP_REQUIREDwhenmfa_verified_atis stale; clients callPOST /api/v1/mfa/step-up/beginto get a freshmfa_pendingtoken, verify a factor, and retry.
All MFA enums travel as strings on the wire ("Totp", "RecoveryCode", "Enabled", "Always", …), never numbers.
Enrollment (/api/v1/mfa/totp/enroll/begin + /confirm), factor management, and recovery-code regeneration are covered on the MFA feature page. Apply the AddMfaFactors and AddRecoveryCodes EF migrations (included below).
Upgrade checklist
- [ ] Generate an RSA (or EC) key pair for token signing.
- [ ] Store the private key PEM in a secret manager; set
JwtSettings:PrivateKeyPem,JwtSettings:ActiveKeyId,JwtSettings:Algorithm. - [ ] Remove
JwtSettings:SecretKeyfrom all services and packages. - [ ] Update every token consumer to validate via
/.well-known/jwks.json(public key), pinningRS256/ES256; ensure no consumer holds a private key. - [ ] Set
JwtSettings:ExpirationMinutesto ≤ 15 (default 10). - [ ] Configure
GrydAuth:Cors:AllowedOriginsand registerAddGrydAuthCors. - [ ] Decide on
GrydAuth:TokenValidation:CacheFailureMode(keepFailSecure). - [ ] Run the
StorePasswordResetTokenHashEF migration; notify users that pending reset links are invalidated. - [ ] Rotate any secret/key that was previously committed to source control.
- [ ] Boot the app in a non-Development environment and confirm it starts (validates config).
- [ ] Verify: forged/HS256/
alg:nonetokens are rejected (401); valid RS256 tokens accepted. - [ ] Review rate-limit defaults under
GrydAuth:Security:RateLimiting; tune per your traffic. - [ ] Make clients handle
429(respectRetry-After) on login, refresh and password-reset. - [ ] Behind a proxy, configure
GrydAuth:ForwardedHeaders(KnownProxies/KnownNetworks) and callUseForwardedHeaders()beforeUseGrydAuth()so the real client IP reaches the limiter. - [ ] Ensure clients treat a failed refresh (degraded mode) as a redirect to login.
- [ ] Review
GrydAuth:PasswordHashing(Argon2id) parameters; plan a forced password reset for existing users (v4 hashes are not verifiable in v5). - [ ] Update clients to stop calling
/api/v1/auth/public-keyand remove theisPasswordEncrypted/isCurrentPasswordEncrypted/isNewPasswordEncryptedfields; send passwords directly over TLS. - [ ] Enforce HTTPS/HSTS (TLS 1.2+) at the app or a trusted ingress.
- [ ] Update any token consumer to read the canonical
token_typeclaim (nottype). - [ ] Add
.Destructure.ByMaskingProperties()to every Serilog configuration. - [ ] Update clients to read
available_tenantsfrom the response body, not the JWT. - [ ] (Optional) To enable MFA: set
GrydAuth:Mfa:SecretProtection(32-byte Base64URL key), reviewMfa:Totp/MfaManagement/MfaPolicy, set each tenant's policy viaPUT /api/v1/admin/tenants/{tenantId}/mfa-policy, and implement themfa_pendingchallenge flow in clients. - [ ] Apply all EF migrations (see below), including
StorePasswordResetTokenHash,UpgradeSeedPasswordToArgon2id,AddMfaFactors, andAddRecoveryCodes.
Applying database migrations
Run the Auth module migrations as part of the deploy:
bash
dotnet ef database update --project src/Modules/Auth/GrydAuth.InfrastructureThis applies every pending v5 migration, including StorePasswordResetTokenHash, UpgradeSeedPasswordToArgon2id, AddMfaFactors, and AddRecoveryCodes.
See also
- MFA — TOTP enrollment, recovery codes, step-up enforcement, per-tenant policy
- Authentication — token types, claims, endpoints
- Security Features — token security, CORS, fail-secure cache policy
- Getting Started — full v5 configuration reference