Skip to content

JWT Authentication

Complete guide to setting up JWT-based authentication with Pragmatic.Identity.Local.Jwt.

The JWT package provides:

  1. Token generationJwtTokenGenerator creates signed JWT tokens after successful login
  2. Token validation — ASP.NET Core JwtBearer handler validates tokens on incoming requests
  3. Claim mappingClaimsPrincipalUserAccessor maps JWT claims to ICurrentUser

All three are configured by a single call to UseJwtAuthentication().

<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
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);
});
});
appsettings.json
{
"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"
}
}
PropertyTypeDefaultDescription
SigningKeystring (required)Symmetric signing key. Must be at least 32 characters (256 bits). Used with HMAC-SHA256.
Issuerstring?nullToken issuer (JWT iss claim). When set, incoming tokens are validated against this value.
Audiencestring?nullToken audience (JWT aud claim). When set, incoming tokens are validated against this value.
TokenExpirationTimeSpan1 hourHow long generated tokens are valid.
ClockSkewTimeSpan1 minuteTolerance for clock differences between token issuer and validator.

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"]);
}
}
// Full signature with explicit parameters
JwtLoginResult Generate(
string subject,
string? displayName = null,
string? tenantId = null,
IEnumerable<string>? roles = null,
IEnumerable<string>? permissions = null);
// Convenience overload from LoginResult
JwtLoginResult Generate(
LoginResult loginResult,
string? displayName = null,
string? tenantId = null,
IEnumerable<string>? roles = null,
IEnumerable<string>? permissions = null);
public sealed record JwtLoginResult(string Token, DateTimeOffset ExpiresAt);
  • Token — the complete JWT string, ready to send to the client
  • ExpiresAt — when the token expires (UTC)

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"
}

When a request arrives with a valid JWT, the claims are mapped as follows:

JWT ClaimICurrentUser PropertyNotes
subIdVia IdentityOptions.UserIdClaimType
nameDisplayNameVia IdentityOptions.DisplayNameClaimType
roleAuthorization.RolesMulti-valued; via IdentityOptions.RoleClaimType
permissionDirect in Claims["permission"]Also fed to CachedPermissionResolver
tenant_idTenantIdVia IdentityOptions.TenantClaimType
issAuthentication.Issuer
subAuthentication.Subject
expAuthentication.ExpiresAtParsed from Unix epoch
auth_timeAuthentication.AuthenticatedAtIf present
amrAuthentication.IsMfaAuthenticatedChecks for “mfa” in amr claim

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/nameidentifiersub
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/namename
http://schemas.microsoft.com/ws/2008/06/identity/claims/rolerole
Custom permission claim typepermission
Custom tenant_id claim typetenant_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.

UseJwtAuthentication() configures the following validation parameters:

ParameterBehavior
ValidateIssuerSigningKeyAlways true — the signing key is always validated
IssuerSigningKeySymmetricSecurityKey from JwtOptions.SigningKey
ValidateIssuertrue if JwtOptions.Issuer is set
ValidIssuerJwtOptions.Issuer
ValidateAudiencetrue if JwtOptions.Audience is set
ValidAudienceJwtOptions.Audience
ValidateLifetimeAlways true — expired tokens are rejected
ClockSkewJwtOptions.ClockSkew (default 1 minute)
NameClaimType"name"
RoleClaimType"role"

To include application-specific claims in the JWT, extend the generation call:

// Option 1: Add as permissions
var permissions = await GetUserPermissions(userId);
var result = jwtGenerator.Generate(
subject: identityKey,
permissions: permissions);
// Option 2: For claims not supported by Generate(), create a wrapper
public 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);
}
}

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;
}
}

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);
}
  • 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
  • Default is 1 hour, which is reasonable for API tokens
  • Shorter lifetimes (15-30 minutes) are more secure but require refresh token logic
  • The ClockSkew setting (default 1 minute) prevents rejecting tokens from servers with minor clock differences

There are two strategies for permissions in JWT tokens:

  1. Embed permissions in token — fast (no DB lookup per request), but token grows with permission count, and changes require re-login
  2. Embed only roles in token — smaller token, but requires RoleExpansionProvider + IRolePermissionStore to 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.