Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> for assistance with diagram creation and documentation prose updates.
11 KiB
title, last-updated, tags
| title | last-updated | tags | ||||
|---|---|---|---|---|---|---|
| JWT token validation architecture | 2026-08-31 |
|
Overview
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
| Token Type | Lifetime | Purpose |
|---|---|---|
| Access Tokens | 1 hour | API authentication |
| Refresh Tokens | 21 days | Obtaining new access tokens |
| Confirmation Tokens | 30 minutes | Email confirmation |
Components
Infrastructure layer
ITokenInfrastructure
Low-level JWT operations.
Methods:
GenerateJwt()- Creates signed JWT tokensValidateJwtAsync()- Validates token signature, expiration, and format
Implementation: JwtInfrastructure.cs
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness
Features.Users slice
ITokenService
Token generation and validation live on the same slice-internal service.
Generation methods:
GenerateAccessToken(Guid userId, string username)- Creates 1-hour access tokenGenerateRefreshToken(Guid userId, string username)- Creates 21-day refresh tokenGenerateConfirmationToken(Guid userId, string username)- Creates 30-minute confirmation token
Validation methods:
ValidateRefreshTokenAsync(string token)- Validates refresh tokensValidateConfirmationTokenAsync(string token)- Validates confirmation tokens
There is no ValidateAccessTokenAsync on ITokenService. Access-token
validation doesn't go through ITokenService at all: JwtAuthenticationHandler
(the host-level auth middleware) calls ITokenInfrastructure.ValidateJwtAsync()
directly, bypassing the Features.Users slice entirely.
Returns (validation): ValidatedToken record containing:
UserId(Guid)Username(string)Principal(ClaimsPrincipal) - Full JWT claims
Implementation: TokenService.cs
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws
UnauthorizedExceptionon validation failure
TokenService is registered by Features.Users' own AddFeaturesUsers()
extension method, except for the lower-level ITokenInfrastructure (JWT
signing/verification) it depends on, which is registered by the host
(API.Core/Program.cs) since JwtAuthenticationHandler (host-level auth
middleware) also depends on it directly.
Integration points
ConfirmUserHandler
Flow:
- Receives confirmation token from user via
ConfirmUserCommand - Calls
ITokenService.ValidateConfirmationTokenAsync() - Extracts user ID from validated token and looks it up via
UserManager<ApplicationUser> - Marks the account confirmed via
IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync(), then persists it viaUserManager<ApplicationUser>.UpdateAsync() - Returns confirmation result
UserManager<ApplicationUser> and IUserEmailStore<ApplicationUser> are
ASP.NET Core Identity's abstractions, not EF Core's default Identity store: the
app backs them with its own DapperUserStore (see
Database), which persists against the
existing UserAccount/UserCredential/UserVerification tables via Dapper,
consistent with the rest of the app's repository pattern.
ResendConfirmationEmailHandler
Flow:
- Receives a user ID via
ResendConfirmationEmailCommand - Looks up the user and checks they aren't already verified
- Generates a fresh confirmation token via
ITokenService - Sends
SendResendConfirmationEmailCommandover MediatR, handled by theFeatures.Emailsslice, with no direct project reference between the two slices
RefreshTokenHandler
Flow:
- Receives a refresh token via
RefreshTokenCommand - Delegates to
ITokenService.RefreshTokenAsync(), which validates the token, retrieves the user account, and issues a new access/refresh token pair - Maps the result onto
LoginPayload
AuthController
Endpoints:
POST /api/auth/register- Register new userPOST /api/auth/login- Authenticate userPOST /api/auth/confirm?token=...- Confirm emailPOST /api/auth/confirm/resend?userId=...- Resend the confirmation emailPOST /api/auth/refresh- Refresh access token
Account management (require a valid access token; user ID comes from the token, never the request body):
PATCH /api/auth/username- Change the authenticated user's usernamePATCH /api/auth/email- Change the authenticated user's email address; resetsEmailConfirmedto false, so the user must re-confirm viaPOST /api/auth/confirm/resendPATCH /api/auth/password- Change the authenticated user's password; requires the current passwordPATCH /api/auth/profile- Update the authenticated user's profile (first name, last name, date of birth)DELETE /api/auth/account- Permanently delete the authenticated user's account; blocked while the account still has associated posts, comments, photos, or follows
Validation security
Token secrets
Three independent secrets enable:
- Key rotation - Rotate each secret type independently
- Isolation - Compromise of one secret doesn't affect others
- Different expiration - Different token types can expire at different rates
Environment Variables:
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
Validation checks
Each token is validated for:
- Signature Verification - Token must be signed with correct secret
- Expiration - Token must not be expired (checked against current time)
- Claims Presence - Required claims (Sub, UniqueName) must be present
- Claims Format - UserId claim must be a valid GUID
Error handling
Validation failures return HTTP 401 Unauthorized. Regardless of the specific
cause (invalid signature, expired token, missing header, malformed claims),
JwtAuthenticationHandler.HandleChallengeAsync always returns the same generic
message: "Unauthorized: Invalid or missing authentication token" — it doesn't
distinguish failure reasons in the response.
Token lifecycle
Access token lifecycle
| Step | Description |
|---|---|
| Generation | During login (1-hour validity) |
| Usage | Included in Authorization header on API requests |
| Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token |
Refresh token lifecycle
| Step | Description |
|---|---|
| Generation | During login (21-day validity) |
| Storage | Client-side (secure storage) |
| Usage | Posted to /api/auth/refresh endpoint |
| Validation | Validated by ITokenService.RefreshTokenAsync() |
| Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days |
Confirmation token lifecycle
| Step | Description |
|---|---|
| Generation | During user registration (30-minute validity) |
| Delivery | Emailed to user in confirmation link |
| Usage | User clicks link, token posted to /api/auth/confirm |
| Validation | Validated by ConfirmUserHandler |
| Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes |
Testing
All of the following live in Features.Users.Tests.
Unit tests
Services/TokenServiceValidationTests.cs
- Happy path: Valid token extraction
- Error cases: Invalid, expired, malformed tokens
- Missing/invalid claims scenarios
Services/TokenServiceRefreshTests.cs
- Successful refresh with valid token
- Invalid/expired refresh token rejection
- Non-existent user handling
Commands/RefreshTokenHandlerTests.cs
- Verifies the handler maps
ITokenService.RefreshTokenAsync()'s result ontoLoginPayload
Commands/ConfirmUserHandlerTests.cs
- Successful confirmation with valid token
- Token validation failures
- User not found scenarios
Commands/ResendConfirmationEmailHandlerTests.cs
- Sends a fresh confirmation email when the user exists and is unverified
- No-ops when the user doesn't exist or is already verified
BDD tests (Reqnroll)
TokenRefresh.feature
- Successful token refresh
- Invalid/expired token rejection
- Missing token validation
Confirmation.feature
- Successful email confirmation
- Expired/tampered token rejection
- Missing token validation
AccessTokenValidation.feature
- Protected endpoint access token validation
- Invalid/expired access token rejection
- Token type mismatch (refresh used as access token)
Future enhancements
Stretch goals
-
Middleware for Access Token Validation - Automatically validate access tokens on protected routes - Populate HttpContext.User from token claims - Return 401 for invalid/missing tokens
-
Token Denylisting - Implement token revocation (for example, on logout) - Store denylisted tokens in cache/database - Check the denylist during validation
-
Refresh Token Rotation Strategy - Detect token reuse (replay attacks) - Automatically invalidate entire token chain on reuse - Log suspicious activity
-
Structured Logging - Log token validation attempts - Track failed validation reasons - Alert on repeated validation failures (brute force detection)