JWT Authentication
Complete guide to setting up JWT-based authentication with Pragmatic.Identity.Local.Jwt.
Overview
Section titled “Overview”The JWT package provides:
- Token generation —
JwtTokenGeneratorcreates signed JWT tokens after successful login - Token validation — ASP.NET Core JwtBearer handler validates tokens on incoming requests
- Claim mapping —
ClaimsPrincipalUserAccessormaps JWT claims toICurrentUser
All three are configured by a single call to UseJwtAuthentication().
1. Add Package Reference
Section titled “1. Add Package Reference”<ItemGroup> <ProjectReference Include="path/to/Pragmatic.Identity.Local.Jwt.csproj" /></ItemGroup>This transitively includes:
Pragmatic.Identity.Local(registration, login, password management)Pragmatic.Identity.AspNetCore(ClaimsPrincipal to ICurrentUser bridge)Pragmatic.Identity(core types)Microsoft.AspNetCore.Authentication.JwtBearer
2. Configure in Program.cs
Section titled “2. Configure in Program.cs”using Pragmatic.Identity.Local.Jwt;
await PragmaticApp.RunAsync(args, app =>{ app.UseJwtAuthentication(jwt => { jwt.SigningKey = app.Configuration["Jwt:Key"]!; jwt.Issuer = "https://myapp.example.com"; jwt.Audience = "https://myapp.example.com"; jwt.TokenExpiration = TimeSpan.FromHours(1); jwt.ClockSkew = TimeSpan.FromMinutes(1); });});3. Add Configuration
Section titled “3. Add Configuration”{ "Jwt": { "Key": "your-super-secret-key-that-is-at-least-32-characters-long!" }}For production, use a secrets manager:
// appsettings.Production.json (or Azure Key Vault, AWS Secrets Manager, etc.){ "Jwt": { "Key": "production-secret-from-vault", "Issuer": "https://api.myapp.com", "Audience": "https://api.myapp.com" }}JwtOptions Reference
Section titled “JwtOptions Reference”| Property | Type | Default | Description |
|---|---|---|---|
SigningKey | string (required) | — | Symmetric signing key. Must be at least 32 characters (256 bits). Used with HMAC-SHA256. |
Issuer | string? | null | Token issuer (JWT iss claim). When set, incoming tokens are validated against this value. |
Audience | string? | null | Token audience (JWT aud claim). When set, incoming tokens are validated against this value. |
TokenExpiration | TimeSpan | 1 hour | How long generated tokens are valid. |
ClockSkew | TimeSpan | 1 minute | Tolerance for clock differences between token issuer and validator. |
Token Generation
Section titled “Token Generation”Using JwtTokenGenerator
Section titled “Using JwtTokenGenerator”After a successful login, generate a JWT token:
public class AuthController(JwtTokenGenerator jwtGenerator){ public JwtLoginResult Login(LoginResult loginResult, UserProfile profile) { return jwtGenerator.Generate( subject: loginResult.ExternalIdentityKey, // e.g., "local|user@example.com" displayName: profile.Name, tenantId: profile.TenantId, roles: ["booking-manager", "catalog-viewer"], permissions: ["reservation.create", "reservation.read"]); }}Generate() Overloads
Section titled “Generate() Overloads”// Full signature with explicit parametersJwtLoginResult Generate( string subject, string? displayName = null, string? tenantId = null, IEnumerable<string>? roles = null, IEnumerable<string>? permissions = null);
// Convenience overload from LoginResultJwtLoginResult Generate( LoginResult loginResult, string? displayName = null, string? tenantId = null, IEnumerable<string>? roles = null, IEnumerable<string>? permissions = null);JwtLoginResult
Section titled “JwtLoginResult”public sealed record JwtLoginResult(string Token, DateTimeOffset ExpiresAt);Token— the complete JWT string, ready to send to the clientExpiresAt— when the token expires (UTC)
Token Structure
Section titled “Token Structure”A generated JWT contains these claims:
{ "sub": "local|user@example.com", "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "iat": 1711043200, "name": "Jane Doe", "tenant_id": "acme-corp", "role": ["booking-manager", "catalog-viewer"], "permission": ["reservation.create", "reservation.read"], "exp": 1711046800, "iss": "https://myapp.example.com", "aud": "https://myapp.example.com"}Claim-to-ICurrentUser Mapping
Section titled “Claim-to-ICurrentUser Mapping”When a request arrives with a valid JWT, the claims are mapped as follows:
| JWT Claim | ICurrentUser Property | Notes |
|---|---|---|
sub | Id | Via IdentityOptions.UserIdClaimType |
name | DisplayName | Via IdentityOptions.DisplayNameClaimType |
role | Authorization.Roles | Multi-valued; via IdentityOptions.RoleClaimType |
permission | Direct in Claims["permission"] | Also fed to CachedPermissionResolver |
tenant_id | TenantId | Via IdentityOptions.TenantClaimType |
iss | Authentication.Issuer | |
sub | Authentication.Subject | |
exp | Authentication.ExpiresAt | Parsed from Unix epoch |
auth_time | Authentication.AuthenticatedAt | If present |
amr | Authentication.IsMfaAuthenticated | Checks for “mfa” in amr claim |
Claim Type Normalization
Section titled “Claim Type Normalization”ClaimsPrincipalUserAccessor normalizes long-form claim type URIs to short names:
| Long Form (from .NET JWT handler) | Normalized To |
|---|---|
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier | sub |
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | name |
http://schemas.microsoft.com/ws/2008/06/identity/claims/role | role |
Custom permission claim type | permission |
Custom tenant_id claim type | tenant_id |
This normalization ensures that ICurrentUser.Claims uses consistent short keys regardless of whether the claim came from a JWT, OIDC provider, or header middleware.
Token Validation
Section titled “Token Validation”UseJwtAuthentication() configures the following validation parameters:
| Parameter | Behavior |
|---|---|
ValidateIssuerSigningKey | Always true — the signing key is always validated |
IssuerSigningKey | SymmetricSecurityKey from JwtOptions.SigningKey |
ValidateIssuer | true if JwtOptions.Issuer is set |
ValidIssuer | JwtOptions.Issuer |
ValidateAudience | true if JwtOptions.Audience is set |
ValidAudience | JwtOptions.Audience |
ValidateLifetime | Always true — expired tokens are rejected |
ClockSkew | JwtOptions.ClockSkew (default 1 minute) |
NameClaimType | "name" |
RoleClaimType | "role" |
Custom Claim Providers
Section titled “Custom Claim Providers”Adding Custom Claims to Tokens
Section titled “Adding Custom Claims to Tokens”To include application-specific claims in the JWT, extend the generation call:
// Option 1: Add as permissionsvar permissions = await GetUserPermissions(userId);var result = jwtGenerator.Generate( subject: identityKey, permissions: permissions);
// Option 2: For claims not supported by Generate(), create a wrapperpublic class EnrichedJwtTokenGenerator(JwtTokenGenerator inner){ public JwtLoginResult GenerateWithCustomClaims( LoginResult login, IEnumerable<Claim> extraClaims) { // Generate base token then add custom claims // Or use the standard Generate() with roles/permissions mapped from your store return inner.Generate(login); }}Reading Custom Claims from ICurrentUser
Section titled “Reading Custom Claims from ICurrentUser”All JWT claims are available via ICurrentUser.Claims:
public class SomeService(ICurrentUser currentUser){ public string? GetDepartment() { return currentUser.Claims.TryGetValue("department", out var values) ? values.FirstOrDefault() : null; }}Integration with Login Flow
Section titled “Integration with Login Flow”A typical login endpoint that combines LoginUser action with JWT generation:
// 1. The LoginUser action validates credentials and returns LoginResult// 2. The endpoint converts LoginResult to JwtLoginResult with user claims
[DomainAction]public partial class LoginUser : DomainAction<LoginResult, ...>{ // Returns LoginResult(ExternalIdentityKey, AuthenticatedAt)}
// In your endpoint handler or event handler:public async Task<JwtLoginResult> HandleLogin( LoginUser loginAction, JwtTokenGenerator jwt, IUserProfileService profiles){ var loginResult = await loginAction.InvokeAsync(); if (!loginResult.IsSuccess) return ...; // Handle error
// Load user profile to get roles/permissions for the token var profile = await profiles.GetByIdentityKey(loginResult.Value.ExternalIdentityKey);
return jwt.Generate( loginResult.Value, displayName: profile.DisplayName, tenantId: profile.TenantId, roles: profile.Roles, permissions: profile.EffectivePermissions);}Security Considerations
Section titled “Security Considerations”Signing Key
Section titled “Signing Key”- Use at least 256 bits (32 characters) for HMAC-SHA256
- Store the key in a secrets manager, not in source code or
appsettings.json - Rotate keys periodically; consider a key rotation strategy for production
Token Lifetime
Section titled “Token Lifetime”- Default is 1 hour, which is reasonable for API tokens
- Shorter lifetimes (15-30 minutes) are more secure but require refresh token logic
- The
ClockSkewsetting (default 1 minute) prevents rejecting tokens from servers with minor clock differences
Permission Embedding
Section titled “Permission Embedding”There are two strategies for permissions in JWT tokens:
- Embed permissions in token — fast (no DB lookup per request), but token grows with permission count, and changes require re-login
- Embed only roles in token — smaller token, but requires
RoleExpansionProvider+IRolePermissionStoreto resolve permissions per request
For most applications, embedding roles and using the expansion provider chain is the recommended approach. Use Identity.Persistence with EfRolePermissionStore for runtime-manageable mappings.
Always use HTTPS in production. JWT tokens sent over HTTP can be intercepted and replayed.