.agents/skills/dotnet-api-security/SKILL.md
Secures ASP.NET Core APIs. Identity, OAuth/OIDC, JWT bearer, passkeys, CORS, rate limiting.
npx skillsauth add dodyg/blue-nile-pds dotnet-api-securityInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
API-level authentication, authorization, and security patterns for ASP.NET Core. This skill owns API auth implementation: ASP.NET Core Identity configuration, OAuth 2.0/OIDC integration, JWT bearer token handling, passkey (WebAuthn) authentication, CORS policies, Content Security Policy headers, and rate limiting.
Cross-references: [skill:dotnet-security-owasp] for OWASP security principles, [skill:dotnet-secrets-management] for secrets handling, [skill:dotnet-cryptography] for cryptographic best practices.
ASP.NET Core Identity provides user management, password hashing, role-based authorization, and two-factor authentication out of the box. It is the recommended starting point for applications that manage their own user accounts.
builder.Services.AddIdentityApiEndpoints<ApplicationUser>(options =>
{
// Password requirements
options.Password.RequiredLength = 12;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = true;
options.Password.RequireDigit = true;
// Lockout
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
// User
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
var app = builder.Build();
app.MapIdentityApi<ApplicationUser>(); // Maps /register, /login, /refresh, /manage endpoints
MapIdentityApi<TUser>() provides pre-built token-based authentication endpoints for SPAs and mobile clients without Razor UI:
| Endpoint | Method | Description |
|----------|--------|-------------|
| /register | POST | Create a new user account |
| /login | POST | Authenticate and receive tokens |
| /refresh | POST | Refresh an expired access token |
| /confirmEmail | GET | Confirm email address |
| /manage/info | GET/POST | Get/update user profile |
| /manage/2fa | POST | Configure two-factor authentication |
For applications that delegate authentication to an external identity provider (Entra ID, Auth0, Okta, Keycloak), configure OIDC middleware.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Oidc:Authority"];
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code; // Authorization Code Flow
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.MapInboundClaims = false; // Preserve original claim types
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "roles";
});
Gotcha: MapInboundClaims = false prevents the Microsoft OIDC handler from remapping standard JWT claims (e.g., sub to http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier). Set this to false to preserve the original claim types from the identity provider.
For API-only scenarios where the client sends a JWT in the Authorization header:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Jwt:Authority"];
options.Audience = builder.Configuration["Jwt:Audience"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromMinutes(1) // Default is 5 min; tighten for security
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Protect endpoints
app.MapGet("/api/profile", (ClaimsPrincipal user) =>
TypedResults.Ok(new { Name = user.Identity?.Name }))
.RequireAuthorization();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"))
.AddPolicy("PremiumUser", policy =>
policy.RequireClaim("subscription", "premium"))
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
.NET 10 introduces built-in passkey (WebAuthn/FIDO2) support for passwordless authentication. Passkeys use public-key cryptography and are phishing-resistant.
// .NET 10: Add passkey support to Identity
builder.Services.AddIdentityApiEndpoints<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders()
.AddPasskeys(); // Enable WebAuthn passkey authentication
var app = builder.Build();
app.MapIdentityApi<ApplicationUser>();
// Passkey registration and authentication endpoints are added automatically
/passkey/register/options to get a PublicKeyCredentialCreationOptions challengenavigator.credentials.create)/passkey/register/passkey/login/options to get a PublicKeyCredentialRequestOptions challengenavigator.credentials.get/passkey/loginKey benefits: No passwords to phish, no credentials stored server-side (only public keys), built-in resistance to replay attacks.
Cross-Origin Resource Sharing (CORS) controls which origins can call your API. Always use explicit, named policies -- never use AllowAnyOrigin() in production.
builder.Services.AddCors(options =>
{
options.AddPolicy("Production", policy =>
{
policy.WithOrigins(
"https://app.example.com",
"https://admin.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)); // Cache preflight
});
options.AddPolicy("Development", policy =>
{
policy.WithOrigins("https://localhost:5173") // Vite dev server
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
var app = builder.Build();
app.UseCors(app.Environment.IsDevelopment() ? "Development" : "Production");
AllowAnyOrigin() + AllowCredentials() is rejected at runtime by ASP.NET Core. But SetIsOriginAllowed(_ => true) + AllowCredentials() silently allows all origins -- never use this pattern.SetPreflightMaxAge, browsers send an OPTIONS request before every cross-origin request. Set a reasonable cache duration (10-60 minutes) to reduce latency.AllowAnyHeader() combined with AllowCredentials() works in ASP.NET Core but may behave unexpectedly in some browsers. Prefer explicit header lists.UseCors() must be called after UseRouting() and before UseAuthorization().Content Security Policy headers prevent XSS, clickjacking, and other injection attacks by controlling which resources the browser can load.
app.Use(async (context, next) =>
{
// API-focused CSP -- restrict all content sources
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'");
// Additional security headers
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy",
"camera=(), microphone=(), geolocation=()");
await next();
});
For APIs serving HTML responses (Razor Pages, Blazor Server), use a more permissive CSP with nonces:
app.Use(async (context, next) =>
{
var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
context.Items["CspNonce"] = nonce;
context.Response.Headers.Append(
"Content-Security-Policy",
$"default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'nonce-{nonce}'");
await next();
});
ASP.NET Core includes built-in rate limiting middleware (Microsoft.AspNetCore.RateLimiting, .NET 7+). Four algorithms are available: fixed window, sliding window, token bucket, and concurrency limiter.
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0; // Reject immediately when limit reached
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/api/products", GetProducts)
.RequireRateLimiting("fixed");
builder.Services.AddRateLimiter(options =>
{
options.AddSlidingWindowLimiter("sliding", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 6; // 10-second segments
limiterOptions.QueueLimit = 0;
});
});
builder.Services.AddRateLimiter(options =>
{
options.AddTokenBucketLimiter("token", limiterOptions =>
{
limiterOptions.TokenLimit = 100;
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
limiterOptions.TokensPerPeriod = 10;
limiterOptions.QueueLimit = 0;
});
});
builder.Services.AddRateLimiter(options =>
{
options.AddConcurrencyLimiter("concurrent", limiterOptions =>
{
limiterOptions.PermitLimit = 10; // Max 10 concurrent requests
limiterOptions.QueueLimit = 5;
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("per-user", httpContext =>
{
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(userId,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1)
});
});
});
Gotcha: UseRateLimiter() must be called after UseRouting() and before UseAuthorization() and endpoint mapping to apply correctly.
AllowAnyOrigin() in production CORS policies -- always specify explicit origins. See [skill:dotnet-security-owasp] for CORS security implications.MapInboundClaims = false when using external OIDC providers -- without it, claim types are remapped to long XML namespace URIs, breaking role and name lookups.appsettings.json -- use user secrets for development and environment variables or managed identity for production. See [skill:dotnet-secrets-management].ClockSkew to TimeSpan.Zero -- small clock differences between token issuer and validator will cause spurious 401 errors. Use 1-2 minutes.UseAuthentication() must come before UseAuthorization(), and UseCors() must come before UseAuthorization().AllowAnyMethod() and AllowAnyHeader() together in production -- explicitly list allowed methods and headers to follow the principle of least privilege./login and /register are common brute-force targets. Apply rate limiting to prevent credential stuffing.TryFromBase64String, length validation) on attacker-controlled input instead.Microsoft.AspNetCore.Authentication.JwtBearer for JWT bearer authenticationMicrosoft.AspNetCore.Authentication.OpenIdConnect for OIDC integrationMicrosoft.AspNetCore.RateLimiting (included in shared framework .NET 7+)testing
Get best practices for TUnit unit testing, including data-driven tests
development
Severity scoring, scorecard computation, confidence levels, and remediation tracking for web accessibility audits. Use when computing page accessibility scores (0-100 with A-F grades), tracking remediation progress across audits, or generating cross-page comparison scorecards.
development
Web content discovery, URL crawling, and page inventory for accessibility audits. Use when scanning web pages, crawling sites for audit scope, or building page inventories for multi-page audits.
development
Audit report formatting, severity scoring, scorecard computation, and compliance export for document accessibility audits. Use when generating DOCUMENT-ACCESSIBILITY-AUDIT.md reports, computing document severity scores (0-100 with A-F grades), creating VPAT/ACR compliance exports, or formatting remediation priorities.