Full reference for JG.AuthKit -- JWT authentication and authorization for .NET APIs.
- Installation
- Quick Start
- Configuration
- Token Issuance
- Token Validation
- Refresh Tokens
- Token Blacklisting
- Refresh Token Storage
- Key Rotation
- Claim Transformation
- Authorization
- Middleware
- Replacing Default Stores
- Advanced Scenarios
dotnet add package JG.AuthKitTargets .NET 8.0 and later.
1. Register services in Program.cs:
builder.Services.AddAuthKit(options =>
{
options.Secret = builder.Configuration["Jwt:Secret"];
options.Issuer = "my-api";
options.Audience = "my-app";
options.AccessTokenExpiry = TimeSpan.FromMinutes(15);
options.RefreshTokenExpiry = TimeSpan.FromDays(30);
});2. Add middleware:
var app = builder.Build();
app.UseAuthKit(); // Adds UseAuthentication() + UseAuthorization()
app.MapControllers();
app.Run();3. Protect endpoints:
app.MapGet("/secure", [Authorize] () => "Hello, authenticated user!");
app.MapGet("/admin", [Authorize(Roles = "admin")] () => "Admin area");4. Issue tokens from a login endpoint:
app.MapPost("/login", async (LoginRequest login, ITokenService tokenService) =>
{
// Validate credentials (your logic here)
var result = await tokenService.IssueTokenAsync(new TokenRequest
{
Subject = user.Id,
Roles = user.Roles.ToList(),
});
return Results.Ok(new
{
result.AccessToken,
result.RefreshToken,
result.ExpiresAt,
result.TokenType,
});
});5. Refresh tokens:
app.MapPost("/refresh", async (RefreshBody body, IRefreshTokenService refreshService) =>
{
var result = await refreshService.RefreshAsync(new RefreshRequest
{
RefreshToken = body.RefreshToken,
});
return Results.Ok(new
{
result.AccessToken,
result.RefreshToken,
result.ExpiresAt,
});
});All options are configured via the AddAuthKit delegate:
builder.Services.AddAuthKit(options =>
{
// --- Signing ---
options.Secret = "your-256-bit-or-longer-secret-key";
// OR provide a SecurityKey directly:
// options.SigningKey = new RsaSecurityKey(rsa);
options.SigningAlgorithm = "HS256"; // Default
// --- Token Lifetime ---
options.Issuer = "my-api";
options.Audience = "my-app";
options.AccessTokenExpiry = TimeSpan.FromMinutes(15); // Default
options.RefreshTokenExpiry = TimeSpan.FromDays(30); // Default
options.ClockSkew = TimeSpan.FromMinutes(1); // Default
// --- Features ---
options.EnableRefreshTokenRotation = true; // Default
options.EnableTokenBlacklist = true; // Default
// --- Cleanup ---
options.CleanupInterval = TimeSpan.FromMinutes(5); // Default
// Set to Timeout.InfiniteTimeSpan to disable background cleanup.
});| Property | Type | Default | Description |
|---|---|---|---|
Secret |
string? |
null |
HMAC secret for symmetric signing. At least 32 bytes UTF-8. |
SigningKey |
SecurityKey? |
null |
Pre-configured key (RSA, ECDSA, etc.). Takes precedence over Secret. |
SigningAlgorithm |
string |
"HS256" |
Algorithm for signing. |
Issuer |
string? |
null |
Token iss claim. Enables issuer validation when set. |
Audience |
string? |
null |
Token aud claim. Enables audience validation when set. |
AccessTokenExpiry |
TimeSpan |
15 min | Access token lifetime. |
RefreshTokenExpiry |
TimeSpan |
30 days | Refresh token lifetime. |
ClockSkew |
TimeSpan |
1 min | Tolerance for token lifetime validation. |
EnableRefreshTokenRotation |
bool |
true |
Issue refresh tokens with one-time-use rotation. |
EnableTokenBlacklist |
bool |
true |
Allow individual token revocation. |
SigningKeys |
List<SigningKeyDescriptor> |
[] |
Additional keys for key rotation. |
CleanupInterval |
TimeSpan |
5 min | Background cleanup interval. |
Used for key rotation. Add multiple keys to AuthKitOptions.SigningKeys:
options.SigningKeys.Add(new SigningKeyDescriptor
{
KeyId = "key-2026-01",
Key = new SymmetricSecurityKey(keyBytes),
Algorithm = "HS256",
ActiveFrom = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
ActiveUntil = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero),
});| Property | Type | Description |
|---|---|---|
KeyId |
string |
Unique key identifier (written to JWT kid header). |
Key |
SecurityKey |
The cryptographic key. |
Algorithm |
string |
Signing algorithm for this key. |
ActiveFrom |
DateTimeOffset? |
When the key becomes active. null = immediately. |
ActiveUntil |
DateTimeOffset? |
When the key stops being used for signing. null = no expiry. |
The first key whose active window includes the current time is used for signing. During validation, all keys (including expired) are tried.
Inject ITokenService to issue, validate, and revoke JWT tokens.
public interface ITokenService
{
ValueTask<TokenResult> IssueTokenAsync(TokenRequest request, CancellationToken ct = default);
ValueTask<TokenValidationOutcome> ValidateTokenAsync(string token, CancellationToken ct = default);
ValueTask<bool> RevokeTokenAsync(string token, CancellationToken ct = default);
}Issue a token:
var result = await tokenService.IssueTokenAsync(new TokenRequest
{
Subject = "user-123",
Roles = ["admin", "editor"],
Claims = [new Claim("tenant", "acme")],
CustomExpiry = TimeSpan.FromHours(1), // Optional override
Audience = "special-aud", // Optional override
});Validate a token:
var outcome = await tokenService.ValidateTokenAsync(tokenString);
if (outcome.IsValid)
{
var userId = outcome.Principal!.FindFirst("sub")?.Value;
}
else
{
Console.WriteLine(outcome.FailureReason);
}Revoke a token:
bool revoked = await tokenService.RevokeTokenAsync(tokenString);| Property | Type | Required | Description |
|---|---|---|---|
Subject |
string |
Yes | User identifier (sub claim). |
Roles |
IReadOnlyList<string>? |
No | Roles to include as role claims. |
Claims |
IReadOnlyList<Claim>? |
No | Additional custom claims. |
CustomExpiry |
TimeSpan? |
No | Override default access token lifetime. |
Audience |
string? |
No | Override default audience. |
| Property | Type | Description |
|---|---|---|
AccessToken |
string |
The signed JWT. |
RefreshToken |
string? |
Refresh token (when rotation is enabled). |
ExpiresAt |
DateTimeOffset |
When the access token expires. |
TokenType |
string |
Always "Bearer". |
Returned by ITokenService.ValidateTokenAsync.
| Property | Type | Description |
|---|---|---|
IsValid |
bool |
Whether the token passed all checks. |
Principal |
ClaimsPrincipal? |
The authenticated principal. null on failure. |
FailureReason |
string? |
Human-readable failure reason. null on success. |
The authentication middleware performs validation automatically. Use ValidateTokenAsync directly when you need manual control (e.g., WebSocket authentication, custom endpoints).
public interface IRefreshTokenService
{
ValueTask<TokenResult> RefreshAsync(RefreshRequest request, CancellationToken ct = default);
ValueTask<bool> RevokeAsync(string refreshToken, CancellationToken ct = default);
ValueTask<bool> RevokeFamilyAsync(string familyId, CancellationToken ct = default);
}Rotation and reuse detection:
Refresh tokens are single-use. Each call to RefreshAsync consumes the current token and issues a new pair (access + refresh). If a consumed token is reused (indicating possible theft), the entire token family is revoked.
// Normal flow:
var result = await refreshService.RefreshAsync(new RefreshRequest
{
RefreshToken = currentRefreshToken,
});
// Use result.AccessToken and result.RefreshToken going forward.Revoke a user's session:
// Revoke by token (revokes the entire family):
await refreshService.RevokeAsync(refreshToken);
// Or revoke by family ID directly:
await refreshService.RevokeFamilyAsync(familyId);| Property | Type | Required | Description |
|---|---|---|---|
RefreshToken |
string |
Yes | The raw refresh token from the client. |
AdditionalClaims |
IReadOnlyList<Claim>? |
No | Extra claims for the new access token. |
Internal model stored by IRefreshTokenStore. Contains:
| Property | Type | Description |
|---|---|---|
TokenHash |
string |
SHA-256 hash of the raw token. |
FamilyId |
string |
Groups tokens from the same login. |
Subject |
string |
The user identifier. |
CreatedAt |
DateTimeOffset |
When created. |
ExpiresAt |
DateTimeOffset |
When it expires. |
IsConsumed |
bool |
Whether it has been used. |
IsRevoked |
bool |
Whether it has been revoked. |
Implement this interface to provide custom blacklist storage (Redis, database, etc.):
public interface ITokenBlacklistStore
{
ValueTask AddAsync(string tokenId, DateTimeOffset expiry, CancellationToken ct = default);
ValueTask<bool> IsBlacklistedAsync(string tokenId, CancellationToken ct = default);
ValueTask<int> CleanupExpiredAsync(CancellationToken ct = default);
}The default implementation. Uses ConcurrentDictionary for lock-free concurrent access. Expired entries are cleaned up by the background service.
Suitable for single-instance deployments. For distributed scenarios, replace with your own implementation:
services.AddSingleton<ITokenBlacklistStore, RedisTokenBlacklistStore>();
services.AddAuthKit(options => { ... });Register your custom store before calling AddAuthKit. The library uses TryAddSingleton, so it won't overwrite an existing registration.
Implement for custom persistence:
public interface IRefreshTokenStore
{
ValueTask StoreAsync(RefreshToken token, CancellationToken ct = default);
ValueTask<RefreshToken?> GetAsync(string tokenHash, CancellationToken ct = default);
ValueTask ConsumeAsync(string tokenHash, CancellationToken ct = default);
ValueTask<bool> RevokeFamilyAsync(string familyId, CancellationToken ct = default);
ValueTask<int> CleanupExpiredAsync(CancellationToken ct = default);
}Default in-memory implementation. Same registration pattern as the blacklist store.
Manages signing credentials and key resolution:
public interface ISigningKeyProvider
{
SigningCredentials GetSigningCredentials();
SecurityKey? ResolveKey(string kid);
IReadOnlyList<SecurityKey> GetValidationKeys();
}Key rotation example:
builder.Services.AddAuthKit(options =>
{
options.Issuer = "my-api";
// Old key (still accepted for validation, no longer used for signing)
options.SigningKeys.Add(new SigningKeyDescriptor
{
KeyId = "key-2025-06",
Key = new SymmetricSecurityKey(oldKeyBytes),
Algorithm = "HS256",
ActiveFrom = new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero),
ActiveUntil = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
});
// Current key
options.SigningKeys.Add(new SigningKeyDescriptor
{
KeyId = "key-2026-01",
Key = new SymmetricSecurityKey(currentKeyBytes),
Algorithm = "HS256",
ActiveFrom = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
});
});Tokens signed with the old key will still validate. New tokens are signed with the current key.
Register one or more transformers to enrich claims during token issuance:
public class DatabaseRoleTransformer : IClaimTransformer
{
private readonly IUserRoleRepository _roles;
public DatabaseRoleTransformer(IUserRoleRepository roles) => _roles = roles;
public async ValueTask<IReadOnlyList<Claim>> TransformAsync(
IReadOnlyList<Claim> claims, CancellationToken cancellationToken)
{
var sub = claims.FirstOrDefault(c => c.Type == "sub")?.Value;
if (sub is null) return claims;
var roles = await _roles.GetRolesAsync(sub, cancellationToken);
var result = new List<Claim>(claims);
foreach (var role in roles)
result.Add(new Claim(ClaimTypes.Role, role));
return result;
}
}Register in DI:
builder.Services.AddSingleton<IClaimTransformer, DatabaseRoleTransformer>();Transformers run in registration order during IssueTokenAsync.
Policy-based role authorization:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.AddRequirements(new RoleRequirement("admin")));
options.AddPolicy("EditorOrAdmin", policy =>
policy.AddRequirements(new RoleRequirement("admin", "editor")));
});The handler checks ClaimTypes.Role claims. Case-insensitive matching.
Policy-based claim authorization:
builder.Services.AddAuthorization(options =>
{
// Require the "tenant" claim (any value):
options.AddPolicy("HasTenant", policy =>
policy.AddRequirements(new ClaimRequirement("tenant")));
// Require specific values:
options.AddPolicy("EngineeringOnly", policy =>
policy.AddRequirements(new ClaimRequirement("department", "engineering", "devops")));
});Convenience method that adds both authentication and authorization middleware:
app.UseAuthKit();
// Equivalent to:
// app.UseAuthentication();
// app.UseAuthorization();The AuthKit authentication handler extracts the Bearer token from the Authorization header, validates it using ITokenService, and populates HttpContext.User.
Standalone blacklist middleware for custom auth setups. Only needed if you are not using the AuthKit authentication handler:
app.UseAuthentication(); // Your custom auth
app.UseAuthKitTokenBlacklist(); // Check blacklist
app.UseAuthorization();Register your implementations before AddAuthKit:
// Redis-backed stores
builder.Services.AddSingleton<ITokenBlacklistStore, RedisTokenBlacklistStore>();
builder.Services.AddSingleton<IRefreshTokenStore, RedisRefreshTokenStore>();
// AuthKit will not overwrite these registrations
builder.Services.AddAuthKit(options => { ... });The default in-memory stores use ConcurrentDictionary and are suitable for single-instance deployments, development, and testing.
var rsa = RSA.Create(2048);
builder.Services.AddAuthKit(options =>
{
options.SigningKey = new RsaSecurityKey(rsa);
options.SigningAlgorithm = "RS256";
options.Issuer = "my-api";
});var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
builder.Services.AddAuthKit(options =>
{
options.SigningKey = new ECDsaSecurityKey(ecdsa);
options.SigningAlgorithm = "ES256";
options.Issuer = "my-api";
});app.MapGet("/ws", async (HttpContext ctx, ITokenService tokenService) =>
{
var token = ctx.Request.Query["access_token"].ToString();
var outcome = await tokenService.ValidateTokenAsync(token);
if (!outcome.IsValid)
{
ctx.Response.StatusCode = 401;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
// Handle WebSocket with outcome.Principal
});builder.Services.AddAuthKit(options =>
{
options.Secret = secret;
options.Issuer = "my-api";
options.EnableRefreshTokenRotation = false;
});
// IssueTokenAsync will return null for RefreshTokenbuilder.Services.AddAuthKit(options =>
{
options.Secret = secret;
options.Issuer = "my-api";
options.EnableTokenBlacklist = false;
});
// RevokeTokenAsync will always return falsebuilder.Services.AddAuthKit(options =>
{
options.Secret = secret;
options.Issuer = "my-api";
options.CleanupInterval = Timeout.InfiniteTimeSpan;
});