Security primitives for .NET — JWT bearer configuration, user context resolution, permission-based authorization, and security headers middleware.
dotnet add package Clywell.Core.Security- Quick Start
- JWT Authentication
- Current User
- Permission-Based Authorization
- Step-Up Authentication
- Custom User Context Resolver
- Custom Claim Mapping
- Security Headers
- API Reference
- Dependencies
builder.Services.AddSecurity(options =>
{
options.AddJwtBearer()
.WithOidcProvider("https://your-identity-provider.com", audience: "your-api");
});app.UseAuthentication();
app.UseUserContext(); // Populates ICurrentUser from the resolved identity
app.UseAuthorization();
app.UseSecurityHeaders(); // Adds OWASP-recommended response headerspublic class MyService(ICurrentUser currentUser)
{
public void DoWork()
{
if (!currentUser.IsAuthenticated)
return;
Console.WriteLine(currentUser.UserId);
Console.WriteLine(currentUser.Email);
Console.WriteLine(currentUser.IpAddress);
if (currentUser.IsInRole("Admin")) { /* ... */ }
if (currentUser.HasPermission("articles.edit")) { /* ... */ }
}
}AddJwtBearer() returns a JwtBearerBuilder. Pick your token source first, then optionally chain transport and advanced settings.
Use WithOidcProvider when tokens are issued by an external provider (Auth0, Azure AD, Keycloak, etc.). Signing keys are discovered automatically.
options.AddJwtBearer()
.WithOidcProvider("https://login.example.com", audience: "my-api");Advanced settings chain naturally:
options.AddJwtBearer()
.WithOidcProvider("https://login.example.com", audience: "my-api")
.WithClockSkew(TimeSpan.FromSeconds(30))
.DisableAudienceValidation(); // only if your IdP omits audUse WithSymmetricKey when your own service issues JWTs with no external OIDC provider.
Security: The signing key must be at least 32 characters. Read it from a secret manager or environment variable — never hard-code it.
options.AddJwtBearer()
.WithSymmetricKey(
signingKey: builder.Configuration["Jwt:SigningKey"], // min 32 chars
issuer: "https://my-service.example.com",
audience: "my-api");Use WithSigningKey when your own service issues JWTs signed with an asymmetric key (RSA, ECDSA). Pass the public SecurityKey derived from your signing key pair. This is the recommended approach for production JWT issuers.
Security: Never include the private key in token validation. Extract only the public parameters before registering — see the example below.
// Load the RSA private key from configuration (secret manager / env var - never hard-code)
using var rsa = RSA.Create();
rsa.ImportFromPem(configuration["Jwt:RsaPrivateKey"]);
var publicKey = new RsaSecurityKey(RSA.Create(rsa.ExportParameters(includePrivateParameters: false)));
builder.Services.AddSecurity(options =>
{
options.AddJwtBearer()
.WithSigningKey(publicKey, issuer: "https://my-service.example.com")
.DisableAudienceValidation(); // omit if you set an audience
});For transports that cannot send an Authorization header (SignalR WebSockets, SSE), chain WithTokenCookie and/or WithTokenQueryParam. Cookie takes priority over query string when both are present.
Security: The cookie should be
HttpOnlyandSecure. Query string tokens may appear in server logs — prefer cookies where possible.
options.AddJwtBearer()
.WithOidcProvider("https://login.example.com", audience: "my-api")
.WithTokenCookie("access_token") // HttpOnly, Secure cookie
.WithTokenQueryParam("access_token"); // fallback when cookie absentICurrentUser is a scoped service populated per-request by UseUserContext(). It exposes:
| Member | Type | Description |
|---|---|---|
UserId |
string? |
Primary subject identifier (sub claim by default) |
Email |
string? |
User email address |
DisplayName |
string? |
Display / full name |
Acr |
string? |
Authentication Context Class Reference; "step-up" for step-up tokens (see AcrValues) |
OperationContext |
string? |
Operation context from step-up proof tokens; identifies the sensitive operation |
IsAuthenticated |
bool |
true when a valid identity was resolved |
IpAddress |
string? |
Remote IP from HttpContext.Connection |
Roles |
IReadOnlySet<string> |
Case-insensitive role set |
Permissions |
IReadOnlySet<string> |
Case-insensitive permission set |
Principal |
ClaimsPrincipal? |
Underlying ASP.NET Core principal |
IsInRole(role) |
bool |
Role membership check |
HasPermission(perm) |
bool |
Permission check |
GetProperty<T>(key) |
T? |
Read a custom property stored in UserInfo.Properties |
UserInfo accepts an optional ImmutableDictionary<string, object> for arbitrary per-request data (e.g. tenant metadata resolved from the token). Access it via GetProperty<T>():
var userInfo = new UserInfo(
userId,
email,
displayName,
roles,
permissions,
Properties: ImmutableDictionary<string, object>.Empty
.Add("tenantId", "tenant-abc")
.Add("plan", "pro"));
// Later, in any service:
var tenantId = currentUser.GetProperty<string>("tenantId");Decorate controllers or actions with [HasPermission]. Multiple attributes require all permissions (AND semantics).
[HasPermission("articles.edit")]
public IActionResult EditArticle(int id) { ... }
[HasPermission("articles.delete")]
[HasPermission("articles.edit")] // user must have BOTH
public IActionResult DeleteArticle(int id) { ... }Policies are resolved dynamically by PermissionPolicyProvider — no manual policy registration required.
Step-up authentication is a re-authentication event: the user proves their identity again for a specific sensitive operation without replacing their existing session token.
Calls continue to send the normal Authorization: Bearer ... header. The step-up proof is sent separately in X-Step-Up-Proof.
Use RequireStepUp() on minimal API endpoints that always require elevated assurance:
app.MapDelete("/account", DeleteAccountHandler)
.RequireStepUp("delete_account");The caller must send a valid proof token (issued by the IAM service's step-up verify endpoint) in X-Step-Up-Proof.
operationContext is optional, but recommended. It scopes the proof token to exactly this operation and helps prevent replay against other step-up endpoints.
Inject IStepUpProofValidator into command handlers or services when step-up depends on runtime conditions:
public class TransferFundsHandler(IStepUpProofValidator stepUpValidator)
{
public Result Handle(TransferFundsCommand command)
{
if (command.Amount > 10_000)
{
var proof = stepUpValidator.Validate("high_value_transfer");
if (proof != StepUpProofValidationResult.Valid)
return Result.Failure("Step-up required for high-value transfers.");
}
// ... proceed
}
}| Value | Meaning |
|---|---|
Valid |
Proof token is valid, has acr=step-up, and required operation context (if any) matches |
Missing |
X-Step-Up-Proof header is missing |
Invalid |
Token is malformed, failed validation, or is not a step-up token |
ContextMismatch |
Token operation_context does not match the required value |
Expired |
Proof token is expired |
Use SecurityHeaderNames.StepUpProof ("X-Step-Up-Proof") when constructing client requests instead of hardcoding the header name.
The default ClaimsUserContextResolver reads identity data straight from JWT claims. To load roles or permissions from a database (or any other source), implement IUserContextResolver.
public class DatabaseUserContextResolver(
IUserRepository userRepo) : IUserContextResolver
{
public async Task<UserInfo?> ResolveAsync(HttpContext context)
{
if (context.User.Identity?.IsAuthenticated != true)
return null;
var userId = context.User.FindFirstValue("sub");
if (userId is null) return null;
var user = await userRepo.GetByIdAsync(userId);
if (user is null) return null;
return new UserInfo(
userId,
user.Email,
user.DisplayName,
user.Roles.ToHashSet(),
user.Permissions.ToHashSet());
}
}
// Registration
builder.Services.AddSecurity(options =>
options.UseResolver<DatabaseUserContextResolver>());Use the factory overload when the resolver depends on services not available at configuration time:
builder.Services.AddSecurity(options =>
options.UseResolver(sp =>
{
var repo = sp.GetRequiredService<IUserRepository>();
return new DatabaseUserContextResolver(repo);
}));ClaimsUserContextResolver reads claims using the names defined in UserClaimMapping. Override any of them via ConfigureClaimMapping() if your identity provider uses non-standard claim types:
builder.Services.AddSecurity(options =>
{
options.AddJwtBearer(jwt => { /* ... */ });
options.ConfigureClaimMapping(mapping =>
{
mapping.UserId = "oid"; // Azure AD object ID
mapping.Email = "preferred_username";
mapping.DisplayName = "name"; // default — shown for clarity
mapping.Roles = "roles"; // Azure AD app roles
mapping.Permissions = "scp"; // OAuth 2 scopes as permissions
});
});Default claim type mapping:
| Property | Default claim type |
|---|---|
UserId |
sub |
Email |
email |
DisplayName |
name |
Roles |
role |
Permissions |
permission |
UseSecurityHeaders() adds OWASP-recommended response headers and strips server-identifying headers. Call it with no arguments to apply the defaults, or supply a configuration action to customise any aspect.
| Header | Default value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
Disables accelerometer, camera, geolocation, gyroscope, magnetometer, microphone, USB |
Content-Security-Policy |
default-src 'self'; frame-ancestors 'none' |
Server and X-Powered-By are removed from every response.
app.UseSecurityHeaders();Pass an Action<SecurityHeadersOptions> to override any value. Set a property to null to suppress that header entirely.
app.UseSecurityHeaders(options =>
{
options.FrameOptions = "SAMEORIGIN"; // relax framing restriction
options.ReferrerPolicy = "no-referrer";
options.PermissionsPolicy = null; // suppress the header
});Use the fluent CspBuilder or supply a raw string:
app.UseSecurityHeaders(options =>
{
options.WithContentSecurityPolicy(csp => csp
.Default("'self'")
.Script("'self'")
.Style("'self'")
.Image("'self'", "data:")
.Font("'self'")
.Connect("'self'")
.FrameAncestors("'none'"));
});Apps that serve a developer UI (e.g. Scalar API reference) at a specific path can register a per-route policy that overrides the global one only for requests under that prefix:
app.UseSecurityHeaders(options =>
{
// Scalar injects inline scripts/styles — allow them only on that route
options.AddRouteContentSecurityPolicy("/scalar", csp => csp
.Default("'self'")
.Script("'self'", "'unsafe-inline'")
.Style("'self'", "'unsafe-inline'")
.Image("'self'", "data:", "https:")
.Font("'self'", "data:")
.Connect("'self'")
.FrameAncestors("'none'"));
});Use IWebHostEnvironment at the call site to apply settings that should only apply in development:
app.UseSecurityHeaders(options =>
{
var connectSrc = app.Environment.IsDevelopment()
? ["'self'", "ws://localhost:*", "wss://localhost:*"] // allow browser-refresh WebSocket
: ["'self'"];
options.WithContentSecurityPolicy(csp => csp
.Default("'self'")
.Connect(connectSrc)
.FrameAncestors("'none'"));
});app.UseSecurityHeaders(options =>
{
options.AddHeader("X-App-Version", "2.1.0"); // add a custom header
options.RemoveHeader("X-AspNet-Version"); // strip an additional header
});| Method | Description |
|---|---|
AddJwtBearer() |
Returns a JwtBearerBuilder to configure JWT bearer authentication |
UseResolver<TResolver>() |
Register a custom IUserContextResolver by type |
UseResolver(Func<IServiceProvider, IUserContextResolver>) |
Register a custom resolver via factory |
ConfigureClaimMapping(Action<UserClaimMapping>) |
Override claim type names read by ClaimsUserContextResolver |
| Method | Description |
|---|---|
WithOidcProvider(authority, audience?) |
Validate tokens from an external OIDC provider |
WithSymmetricKey(signingKey, issuer, audience?) |
Validate locally-issued tokens with a symmetric key |
WithSigningKey(signingKey, issuer, audience?) |
Validate tokens signed with a pre-built SecurityKey (RSA, ECDSA, etc.). Pass the public key for asymmetric schemes. |
WithTokenCookie(cookieName) |
Read bearer token from an HttpOnly cookie (SignalR / SSE) |
WithTokenQueryParam(parameterName) |
Fallback: read bearer token from a query string parameter |
DisableHttpsMetadataRequirement() |
Allow HTTP for OIDC discovery. Never in production. |
DisableIssuerValidation() |
Skip iss claim validation |
DisableAudienceValidation() |
Skip aud claim validation |
DisableLifetimeValidation() |
Skip token expiry check. Never in production. |
WithClockSkew(TimeSpan) |
Override clock skew tolerance (default: 1 minute) |
PreserveInboundClaimTypes() |
Keep WS-Federation claim type URIs instead of mapping to short names |
Scoped service available after UseUserContext() runs in the pipeline. See the Current User section for the full member table.
Configure via app.UseSecurityHeaders(options => { ... }).
| Member | Description |
|---|---|
ContentTypeOptions |
X-Content-Type-Options value; null suppresses the header (default: nosniff) |
FrameOptions |
X-Frame-Options value; null suppresses (default: DENY) |
ReferrerPolicy |
Referrer-Policy value; null suppresses (default: strict-origin-when-cross-origin) |
PermissionsPolicy |
Permissions-Policy value; null suppresses |
WithContentSecurityPolicy(string?) |
Set a raw CSP string, or null to suppress |
WithContentSecurityPolicy(Action<CspBuilder>) |
Build a CSP using the fluent builder |
AddRouteContentSecurityPolicy(string, string) |
Override CSP for requests under a path prefix |
AddRouteContentSecurityPolicy(string, Action<CspBuilder>) |
Same, using the builder |
AddHeader(name, value) |
Inject an additional response header |
RemoveHeader(name) |
Remove a response header (in addition to Server / X-Powered-By) |
| Method | CSP directive |
|---|---|
Default(sources) |
default-src |
Script(sources) |
script-src |
Style(sources) |
style-src |
Image(sources) |
img-src |
Font(sources) |
font-src |
Connect(sources) |
connect-src |
FrameAncestors(sources) |
frame-ancestors |
Media(sources) |
media-src |
Object(sources) |
object-src |
Worker(sources) |
worker-src |
FormAction(sources) |
form-action |
Build() |
Returns the assembled CSP string |
public sealed record UserInfo(
string UserId,
string? Email = null,
string? DisplayName = null,
IReadOnlySet<string>? Roles = null,
IReadOnlySet<string>? Permissions = null,
ImmutableDictionary<string, object>? Properties = null,
string? Acr = null,
string? OperationContext = null);Returned by IUserContextResolver.ResolveAsync() to describe the resolved identity for the current request.
Constants for common JWT claim type names:
SecurityClaimTypes.Subject // "sub"
SecurityClaimTypes.Email // "email"
SecurityClaimTypes.Name // "name"
SecurityClaimTypes.Role // "role"
SecurityClaimTypes.Permission // "permission"
SecurityClaimTypes.Acr // "acr"
SecurityClaimTypes.OperationContext // "operation_context"Well-known Authentication Context Class Reference (acr) values:
AcrValues.Password // "pwd"
AcrValues.Mfa // "mfa"
AcrValues.StepUp // "step-up"
AcrValues.Social // "social"
AcrValues.ApiKey // "api_key"Header name constants used by the security infrastructure:
SecurityHeaderNames.StepUpProof // "X-Step-Up-Proof"Microsoft.AspNetCore.App(framework reference — no extra NuGet download)Microsoft.AspNetCore.Authentication.JwtBearer
MIT — see LICENSE.