Help us improve
Share bugs, ideas, or general feedback.
From dotnet-pilot
JWT bearer auth, ASP.NET Identity, OIDC, and policy-based authorization patterns for ASP.NET Core APIs.
npx claudepluginhub zdanovichnick/dotnet-pilot --plugin dotnet-pilotHow this skill is triggered — by the user, by Claude, or both
Slash command
/dotnet-pilot:authenticationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Reference for securing ASP.NET Core APIs. Covers JWT bearer, ASP.NET Identity, OIDC, policy-based authorization, and resource-based authorization. Used by `dnp-planner`, `dnp-api-scaffolder`, and `dnp-tdd-developer-hard`.
Measures whether skills, rules, and agent definitions are actually followed by auto-generating test scenarios at 3 strictness levels and reporting compliance rates with full tool call timelines.
Share bugs, ideas, or general feedback.
Reference for securing ASP.NET Core APIs. Covers JWT bearer, ASP.NET Identity, OIDC, policy-based authorization, and resource-based authorization. Used by dnp-planner, dnp-api-scaffolder, and dnp-tdd-developer-hard.
| Scenario | Approach |
|---|---|
| API consumed by SPAs or mobile apps with an external IdP | JWT Bearer |
| Server-rendered app with local user accounts + roles | ASP.NET Identity |
| Federated login (Google, Entra ID, Keycloak) | OIDC + AddOpenIdConnect |
| Fine-grained permissions beyond roles | Policy-based + IAuthorizationHandler |
| Resource ownership check (user can only edit their own order) | Resource-based authorization |
// Order matters — authentication must run before authorization
app.UseAuthentication(); // sets HttpContext.User
app.UseAuthorization(); // evaluates policies
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Authority issues and validates tokens (OIDC discovery endpoint)
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
// Zero skew — reject tokens within the default 5-minute grace window
ClockSkew = TimeSpan.Zero
};
// Map non-standard claim names (e.g., Keycloak uses "preferred_username")
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "preferred_username";
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = ctx =>
{
logger.LogWarning(ctx.Exception, "JWT authentication failed");
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Auth:Secret"]!)),
ValidIssuer = builder.Configuration["Auth:Issuer"],
ValidAudience = builder.Configuration["Auth:Audience"],
ClockSkew = TimeSpan.Zero
};
})
// Custom user entity extending IdentityUser
public class ApplicationUser : IdentityUser
{
public string DisplayName { get; set; } = string.Empty;
public string? AvatarUrl { get; set; }
}
// Registration
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequiredLength = 12;
options.Password.RequireNonAlphanumeric = false;
options.User.RequireUniqueEmail = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
Combine with JWT — add AddJwtBearer after AddIdentity to issue tokens on login:
builder.Services.AddAuthentication()
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { ... });
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://your-idp.example.com";
options.ClientId = builder.Configuration["OIDC:ClientId"];
options.ClientSecret = builder.Configuration["OIDC:ClientSecret"];
options.ResponseType = "code"; // Authorization Code Flow
options.Scope.Add("email");
options.Scope.Add("profile");
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
// Map IdP-specific claim names to standard .NET claim types
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "preferred_username");
});
builder.Services.AddAuthorization(options =>
{
// Role-based (simple)
options.AddPolicy("AdminOnly",
policy => policy.RequireRole("Admin"));
// Claim-based
options.AddPolicy("CanEditOrders",
policy => policy.RequireClaim("permission", "orders:write"));
// Custom assertion — runs inline logic against ClaimsPrincipal
options.AddPolicy("InternalService",
policy => policy.RequireAssertion(ctx =>
ctx.User.HasClaim("client_type", "service") &&
ctx.User.IsInRole("ServiceAccount")));
// Multiple requirements (all must pass)
options.AddPolicy("SeniorEditor",
policy => policy
.RequireRole("Editor")
.RequireClaim("experience_years", ["5", "6", "7", "8", "9", "10+"]));
// Require authenticated user (baseline for all endpoints)
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
// Minimal API
app.MapGet("/orders", GetOrders) .RequireAuthorization();
app.MapPost("/orders", CreateOrder) .RequireAuthorization("CanEditOrders");
app.MapDelete("/orders/{id}", DeleteOrder) .RequireAuthorization("AdminOnly");
app.MapGet("/health", HealthCheck) .AllowAnonymous();
// Controller action
[Authorize(Policy = "CanEditOrders")]
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, ...) { }
// Controller-level with action-level override
[Authorize]
public class OrdersController : ControllerBase
{
[AllowAnonymous]
[HttpGet("public")]
public IActionResult GetPublic() => Ok();
}
Use when the policy decision requires loading the resource being accessed.
public record ResourceOwnerRequirement : IAuthorizationRequirement;
public class ResourceOwnerHandler(IHttpContextAccessor httpContextAccessor)
: AuthorizationHandler<ResourceOwnerRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx,
ResourceOwnerRequirement requirement,
Order resource)
{
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (resource.OwnerId == userId || ctx.User.IsInRole("Admin"))
ctx.Succeed(requirement);
// else: do nothing — ctx remains un-succeeded (implicit deny)
return Task.CompletedTask;
}
}
// Registration
builder.Services.AddScoped<IAuthorizationHandler, ResourceOwnerHandler>();
app.MapPut("/orders/{id}", async (
int id,
UpdateOrderRequest req,
IAuthorizationService authz,
ClaimsPrincipal user,
OrderService svc,
CancellationToken ct) =>
{
var order = await svc.GetByIdAsync(id, ct);
if (order is null) return TypedResults.NotFound();
var authResult = await authz.AuthorizeAsync(user, order, new ResourceOwnerRequirement());
if (!authResult.Succeeded) return TypedResults.Forbid();
var result = await svc.UpdateAsync(id, req, ct);
return result.Match(
updated => TypedResults.Ok(updated),
error => TypedResults.Problem(error.Message));
});
// In a minimal API endpoint
app.MapGet("/me", (ClaimsPrincipal user) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var email = user.FindFirstValue(ClaimTypes.Email);
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value);
var permissions = user.FindAll("permission").Select(c => c.Value);
return TypedResults.Ok(new { userId, email, roles, permissions });
}).RequireAuthorization();
// In a service (inject IHttpContextAccessor)
public class CurrentUserService(IHttpContextAccessor accessor)
{
public string UserId =>
accessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new InvalidOperationException("No authenticated user in context");
public bool IsAdmin =>
accessor.HttpContext?.User.IsInRole("Admin") ?? false;
}
// Register
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CurrentUserService>();
When an API must accept both JWT (machine-to-machine) and cookie (browser) auth:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Smart";
options.DefaultChallengeScheme = "Smart";
})
.AddPolicyScheme("Smart", "Smart", options =>
{
options.ForwardDefaultSelector = ctx =>
ctx.Request.Headers.ContainsKey("Authorization")
? JwtBearerDefaults.AuthenticationScheme
: CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => { ... })
.AddCookie(options => { ... });
| Do | Don't |
|---|---|
Set ClockSkew = TimeSpan.Zero | Allow the default 5-minute token expiry grace period in production |
Validate both iss and aud | Disable issuer or audience validation for convenience |
Use FallbackPolicy to require auth by default | Rely on [Authorize] placement — it's easy to forget |
| Use policy-based authorization for permissions | Hard-code role strings in [Authorize(Roles = "...")] throughout controllers |
| Use resource-based authorization for ownership checks | Put ownership logic inside domain services |
Map claim names explicitly with ClaimActions.MapJsonKey | Assume IdP claim names match ClaimTypes.* constants |
| Store permissions as claims in the token | Re-query the DB for permissions on every request |
Use RequireAuthenticatedUser() as fallback policy | Open all endpoints and add [Authorize] selectively |
AllowAnonymous() on health check / public endpoints | Forget to exempt health endpoints from the fallback policy |
skills/error-handling/SKILL.md — TypedResults.Forbid() and mapping auth failures to ProblemDetailsskills/aspnet-api-patterns/SKILL.md — endpoint conventions for protected routes