This guide covers the authentication and authorization system in the BookStore application. The system uses a JWT-based architecture where the Blazor Server frontend acts as a client to the backend API, storing tokens in memory for security.
The system uses a pure Token-Based approach to unify the auth model for web, mobile, and third-party clients.
graph TB
subgraph "Blazor Frontend"
A[User Login] --> B[AuthenticationService]
B --> C["TokenService (In-Memory)"]
C --> D[JwtAuthenticationStateProvider]
D --> E[Notify State Changed]
end
subgraph "Backend API"
F[Identity Endpoints] --> G[JwtTokenService]
G --> H["Issue Access/Refresh Tokens"]
I[API Requests] --> J[Authorization Header]
J --> K[Validate Bearer Token]
end
B --> F
AuthenticationService: High-level service for Login, Register, and Logout operations.TokenService: Stores Access and Refresh tokens in Scoped Memory (per user session), keyed by tenant ID.- Tokens are stored as
Dictionary<string, (AccessToken, RefreshToken)>— one entry per tenant — supporting multi-tenant sessions within the same Blazor circuit. - Security Note: Tokens are NOT stored in LocalStorage or Cookies to prevent XSS attacks.
- Tokens persist only for the lifetime of the user's session (browser tab).
- Tokens are stored as
JwtAuthenticationStateProvider: Custom provider that:- Reads tokens from
TokenServicefor the current tenant (subscribes toTenantService.OnChangeto re-evaluate state on tenant switch). - Parses JWT claims to set the user's
AuthenticationState. - Automatically handles Silent Refresh 5 minutes before token expiry (background) or immediately on request if the token has already expired.
- Reads tokens from
JwtTokenService: Central service for generating access tokens, rotating refresh tokens, and building standardized user claims. Signing algorithm resolution is shared with API validation: explicitJwt:Algorithmwins, otherwiseRS256is auto-selected when both RS256 keys are configured, else it falls back toHS256.JwtAuthenticationEndpoints:POST /account/login: Exchange credentials for tokens.POST /account/resend-verification: Returns a generic success payload to avoid account enumeration; validation failures (for example, missing email) return RFC7807ProblemDetailswith a machine-readable error code.POST /account/refresh-token: Exchange refresh token for new access token (with automatic rotation).
- Notification SSE endpoint:
GET /api/notifications/streamremains anonymous for public read-model updates, but it is protected by a dedicatedNotificationSsePolicyrate limiter (tenant+client IP partitioning) to reduce connection-exhaustion abuse. - Notification test trigger endpoint:
POST /api/notifications/test-notificationis available only in Development and also requires authenticatedAdminauthorization. MartenUserStore: Custom Identity store implementingIUserSecurityStampStore,IUserLockoutStore, andIUserTwoFactorStorefor full Identity compatibility.- Passkey Integration: Passkey login flow (
/account/assertion/result) also results in the issuance of standard JWTs, making the frontend agnostic to how the user logged in. Passkey registration flow (/account/attestation/result) returns generic attestation failure messages to clients and keeps detailed failure diagnostics in server logs. Passkey device names derived fromUser-Agentare sanitized and HTML-encoded before persistence. During passkey assertion, lookup (FindByPasskeyIdAsync) validates at runtime that the active Marten session tenant matches the request tenant context as a defense-in-depth tenant-isolation invariant. The same runtime enforcement applies toFindByNameAsyncandFindByEmailAsync. A violation logs aCritical-level message and silently returnsnull(deny access) rather than throwing — to avoid leaking information.
- HS256 minimum secret length:
Jwt:SecretKeymust be at least 32 bytes when encoded as UTF-8. - Startup validation enforces this rule for HS256 configuration.
- In non-development environments, HS256 secrets must also pass baseline strength checks:
- Not all-identical characters (for example,
aaaaaaaa...). - At least 4 distinct characters.
- Must not match known placeholder/default-like values, including normalized variants of the legacy default key (case and separator differences are treated as equivalent).
- Not all-identical characters (for example,
JwtTokenServicealso enforces this rule when creating signing credentials as a defense-in-depth guard.- Production recommendation: Prefer
RS256with a managed asymmetric key pair (for example, Azure Key Vault-backed key material).HS256remains supported for compatibility, but startup emits a warning when a non-development environment resolves toHS256.
Algorithm resolution order (used by both token issuance and token validation):
- If
Jwt:Algorithmis explicitly set, that value is used (HS256orRS256). - If
Jwt:Algorithmis omitted and bothJwt:RS256:PrivateKeyPemandJwt:RS256:PublicKeyPemare present,RS256is auto-selected. - If
Jwt:Algorithmis omitted and RS256 keys are absent, it falls back toHS256.
Recommended production configuration (explicit RS256):
{
"Jwt": {
"Algorithm": "RS256",
"Issuer": "BookStore.ApiService",
"Audience": "BookStore.Web",
"RS256": {
"PrivateKeyPem": "-----BEGIN PRIVATE KEY-----...",
"PublicKeyPem": "-----BEGIN PUBLIC KEY-----..."
}
}
}Alternative production configuration (auto-select RS256):
{
"Jwt": {
"Issuer": "BookStore.ApiService",
"Audience": "BookStore.Web",
"RS256": {
"PrivateKeyPem": "-----BEGIN PRIVATE KEY-----...",
"PublicKeyPem": "-----BEGIN PUBLIC KEY-----..."
}
}
}- Tenant admin seeding now requires an explicit password outside Development/Test contexts.
- Configure a default seed password with
Seeding:AdminPassword(or the environment variableSeeding__AdminPassword) when startup seeding is enabled. - In Development/Test only, if no explicit password is provided, the legacy fallback
Admin123!is still used to keep local and automated test flows deterministic. - For CI, staging, and production environments, always provide
Seeding__AdminPasswordthrough your secret store or deployment pipeline variables.
- Startup configuration validation now blocks
Email:DeliveryMethod=Noneoutside Development. - This allows local development flows that skip email delivery while failing fast in Test, Staging, and Production if email delivery is disabled.
- Use
Email:DeliveryMethod=Loggingfor non-delivery diagnostics orEmail:DeliveryMethod=Smtpfor real delivery in non-development environments.
Authentication:Passkey:AllowedOriginsis validated during startup.- In non-development environments, at least one allowed origin is required.
- In Development, an empty list is allowed to keep local workflows flexible.
- Each configured origin must be an absolute
http/httpsorigin and must not include a path, query string, fragment, or user-info. - Trailing slash variants are normalized to canonical origin format (
https://host:port) and reused by both passkey origin checks and CORS policy configuration.
Example configuration:
{
"Authentication": {
"Passkey": {
"AllowedOrigins": [
"https://localhost:7260",
"https://bookstore.example.com"
]
}
}
}Example configuration:
{
"Seeding": {
"Enabled": true,
"AdminPassword": "ChangeThisForYourEnvironment!"
}
}Authentication is tightly integrated with multi-tenancy to prevent cross-tenant access.
Every JWT access token includes a tenant_id claim:
{
"sub": "user-guid",
"email": "user@example.com",
"tenant_id": "acme",
"role": "Admin"
}Refresh tokens follow a strict rotation policy:
- Single-session enforcement on login: Successful password and passkey logins clear all previously issued refresh tokens before issuing a new one.
- Rotation: A new refresh token is issued every time an access token is refreshed. The old token is marked as used (not deleted) to enable replay detection.
- History: The latest active tokens plus recently-used ones (within 24 hours) are retained per user for security/concurrency balance.
- Tenant Context: Refresh tokens store their originating tenant for defense-in-depth.
- Security Stamp Snapshot: Each refresh token captures the user's security stamp at issuance. If the user's stamp has since changed (e.g., password reset), the token is rejected.
- Token Families: Each login session starts a new token family (
FamilyId). Replaying a used token invalidates all tokens in that family (refresh token theft detection).
Single-session enforcement is verified by integration tests for both login mechanisms:
AuthTests.Login_ShouldInvalidatePreviousRefreshTokensPasskeySecurityTests.PasskeyLogin_ClearsAllExistingRefreshTokens
public record RefreshTokenInfo(
string Token, // SHA-256 hash of the raw token (never stored in plaintext)
DateTimeOffset Expires,
DateTimeOffset Created,
string TenantId, // Prevents cross-tenant token usage
string SecurityStamp, // Snapshot at issuance for replay detection
string FamilyId, // Groups related tokens for theft detection
bool IsUsed = false); // Marked true after rotation, kept for replay detectionRefresh tokens are never stored in plaintext. The raw token is issued to the client once, then immediately hashed before persistence.
// Generate a cryptographically secure raw token (64 random bytes)
public string GenerateRefreshToken()
{
var randomBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes);
}
// Compute a deterministic SHA-256 hash for storage and lookup
public string HashRefreshToken(string refreshToken)
{
var tokenBytes = Encoding.UTF8.GetBytes(refreshToken);
var hash = SHA256.HashData(tokenBytes);
return Convert.ToHexString(hash);
}All lookups (login, token refresh, logout) hash the incoming plaintext value before querying stored tokens. A database compromise exposes only hashes — the raw tokens cannot be recovered.
Refresh token validation uses a tenant-first lookup path:
- First query the current tenant context for the refresh token hash.
- Only if not found, perform a cross-tenant fallback lookup for theft-detection handling.
This keeps the common path scoped and fast while preserving cross-tenant security checks.
MartenUserStore implements IUserSecurityStampStore. Every JWT access token must include a security_stamp claim. On each authenticated API request the OnTokenValidated handler enforces claim presence and verifies the token's stamp still matches the current value in the database:
OnTokenValidated = async context =>
{
var cache = context.HttpContext.RequestServices.GetRequiredService<HybridCache>();
// ...
var currentSecurityStamp = await cache.GetOrCreateAsync(
key: SecurityStampCache.GetCacheKey(tenantId, userGuid),
factory: async ct =>
{
var user = await session.Query<ApplicationUser>()
.FirstOrDefaultAsync(u => u.Id == userGuid, ct);
return user?.SecurityStamp ?? "__missing__";
},
options: SecurityStampCache.CreateEntryOptions(), // 30 s L2 / 15 s L1
tags: [SecurityStampCache.GetCacheTag(tenantId, userGuid)]);
if (currentSecurityStamp == "__missing__") // User deleted
context.Fail("User not found.");
if (string.IsNullOrEmpty(tokenSecurityStamp))
context.Fail("Token missing required security stamp claim.");
else if (tokenSecurityStamp != currentSecurityStamp)
context.Fail("Token has been revoked due to security stamp change.");
};The entire handler body is wrapped in a try/catch. Any unexpected exception (e.g., transient database error, cache failure) is caught, logged at Error level, the ClaimsPrincipal is cleared, and context.Fail("Authentication failed.") is called. This ensures the exception is never silently swallowed or allowed to propagate as an unhandled middleware exception.
The stamp is cached with a 30 s L2 / 15 s L1 TTL (intentionally short) to avoid a database round-trip on every request. The cache is tag-invalidated immediately after any security event:
Tokens missing security_stamp are rejected with 401 Unauthorized.
| Event | Invalidation trigger |
|---|---|
| Password change | JwtAuthenticationEndpoints.ChangePasswordAsync |
| Password removed | JwtAuthenticationEndpoints.RemovePasswordAsync |
| Password added | JwtAuthenticationEndpoints.AddPasswordAsync |
| Passkey added | PasskeyEndpoints attestation result handler |
| Passkey deleted | PasskeyEndpoints delete handler |
- Security Stamp:
MartenUserStoreimplementsIUserSecurityStampStore, allowing global token invalidation (e.g., on password change).
JWT access token validation uses a 30-second clock skew tolerance:
ClockSkew = TimeSpan.FromSeconds(30)This keeps validation strict while avoiding false 401 responses from minor client/server clock drift.
- Production access token lifetime is 15 minutes (per OWASP recommendations for stateless JWTs).
JwtTokenServicedefaults to 15 minutes whenJwt:ExpirationMinutesis missing or invalid.- When
Jwt:ExpirationMinutesis present but invalid (non-numeric or <= 0), the API logs aWarningand falls back to the 15-minute default. - An explicit
Jwt:ExpirationMinutesvalue still overrides the default. - Refresh token rotation provides seamless session continuity despite the short access token lifetime.
TenantSecurityMiddleware enforces tenant isolation for every request:
| Scenario | Result |
|---|---|
Authenticated: JWT tenant_id missing |
403 Forbidden |
Authenticated: JWT tenant_id ≠ request tenant |
403 Forbidden (cross-tenant access denied) |
| Anonymous: request targets a non-default tenant | 403 Forbidden (anonymous access to tenant data denied) |
| Authenticated or anonymous: tenant matches | Pass-through |
Endpoints can opt out of this check by declaring [AllowAnonymousTenant] metadata (used by public endpoints such as /account/login, /api/configuration, and SSE notifications).
Standard email/password login flow.
Endpoint: POST /account/login
Request: { "email": "...", "password": "..." }
Response:
{
"tokenType": "Bearer",
"accessToken": "ey...",
"expiresIn": 900,
"refreshToken": "..."
}Password validation limits:
- Minimum length: 12 characters.
- Maximum length: 128 characters.
- Validation message (minimum): "At least 12 characters".
- Reason: Mitigates oversized-input DoS risk while still allowing long passphrases.
- Boundary behavior: 128 characters is valid; 129 characters is invalid.
- Validation message: "At most 128 characters".
The application supports WebAuthn/FIDO2 for passwordless login. This flow is fully integrated with the JWT system.
Flow:
- Frontend gets assertion options (
/account/assertion/options). - User authenticates with FaceID/TouchID.
- Frontend sends assertion to
/account/assertion/result. - Backend issues JWT tokens just like a password login.
See Passkey Guide for implementation details.
The API enforces a hard invariant: an account must always keep at least one sign-in factor available (password or passkey).
Supported recovery flows:
-
User is still signed in on a trusted device
- Add a password using
POST /account/add-password. - Register a replacement passkey.
- Remove the old passkey only after a replacement factor exists.
- Add a password using
-
User has another passkey
- Sign in with the backup passkey.
- Register any needed replacement passkey.
- Remove the old passkey only after confirming another factor is available.
-
User lost all passkeys and has no password
- Self-service recovery is not available.
- Admin-assisted recovery is required.
- There is currently no dedicated admin API endpoint for passkey re-enrollment recovery.
- Operations/support must perform manual recovery and keep the account locked until the user re-enrolls at least one authentication factor.
For safety, two endpoint guards return actionable RFC 7807 responses:
POST /account/remove-passwordis blocked when the user has zero passkeys.DELETE /account/passkeys/{id}is blocked when deleting the last passkey on an account with no password.
We store tokens in a Scoped service (TokenService). This means:
- Pros: Immune to XSS — malicious JavaScript running in the browser cannot access service memory.
- Cons: User is logged out if they refresh the page (F5) or the Blazor circuit is recreated.
This is the intentional, final design for Blazor Server. Because all code (including TokenService) executes server-side, tokens are never serialised to the browser — not even as HttpOnly cookies. Refreshing the page requires re-login, which is an acceptable trade-off for high-security applications.
All outgoing HTTP requests to the API are intercepted by AuthorizationMessageHandler, which attaches the Bearer token for the current tenant and forwards additional request metadata:
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// Propagate distributed tracing identifiers
request.Headers.Add("X-Correlation-ID", _clientContextService.CorrelationId);
request.Headers.Add("X-Causation-ID", _clientContextService.CausationId);
// Attach the access token for the active tenant
var token = _tokenService.GetAccessToken(_tenantService.CurrentTenantId);
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
// Forward the original browser User-Agent and client IP
// (used for passkey device naming and rate-limit partitioning)
...
return await base.SendAsync(request, cancellationToken);
}Role-based authorization is enforced via standard ASP.NET Core policies.
- Admin: Full access to management endpoints.
- User: Standard access (can manage own profile/orders).
| Policy | Requirement |
|---|---|
Admin |
Role Admin or ADMIN (case-insensitive alias) |
| Policy | Requirement |
|---|---|
SystemAdmin |
Role Admin and tenant_id == default tenant |
The SystemAdmin policy is used in the Blazor frontend to gate cross-tenant administration UI that is only available to admins of the default tenant.
Endpoints are protected using the [Authorize] attribute or .RequireAuthorization() extension method.
app.MapPost("/api/admin/books", ...)
.RequireAuthorization("Admin");Users are stored in Marten (PostgreSQL) as JSON documents, scoped per tenant.
```csharp
public class ApplicationUser
{
public Guid Id { get; set; }
public string Email { get; set; }
public string PasswordHash { get; set; }
public string SecurityStamp { get; set; } // Rotated on every security event
public bool LockoutEnabled { get; set; }
public DateTimeOffset? LockoutEnd { get; set; }
public int AccessFailedCount { get; set; }
public ICollection<string> Roles { get; set; }
public IList<RefreshTokenInfo> RefreshTokens { get; set; } // Hashed tokens only
public IList<UserPasskeyInfo> Passkeys { get; set; }
}To protect against abuse and Denial of Service (DoS) attacks, all authentication endpoints are protected by the AuthPolicy.
- Partition key:
tenantId:clientIp.- Uses resolved tenant context plus caller IP address.
- Does not use request-body fields (for example,
email) for partitioning. - This prevents attackers from bypassing throttles by rotating email values.
- Limit: Configurable via auth-specific settings:
RateLimit:AuthPermitLimitRateLimit:AuthWindowSecondsRateLimit:AuthQueueLimit
- Scope: Applied globally to all endpoint in the
/accountgroup (Login, Register, Passkeys, includingGET /account/passkeysandDELETE /account/passkeys/{id}). - Response:
429 Too Many Requestswhen exceeded.
The policy remains tenant-aware while preserving anonymity-safe behavior: responses do not expose whether a specific email exists, and throttling is enforced at the tenant+IP boundary.