ASP.NET Core is one of the most secure web frameworks available — when used correctly. The framework ships with authentication middleware, CSRF protection, data protection APIs, and built-in output encoding in Razor. But “available” is not the same as “enabled by default,” and the most common ASP.NET Core vulnerabilities come from developers bypassing built-in protections rather than from framework weaknesses.
This guide covers the essential ASP.NET Core security best practices for 2026 — what the framework provides, where the common pitfalls are, and how to verify your application is secure using static analysis (SAST) and dynamic testing (DAST).
1. Authentication and Authorization
Use ASP.NET Core Identity or a Standards-Based Auth Provider
For most applications, ASP.NET Core Identity is the right starting point. It handles password hashing (using PBKDF2 with SHA-512), account lockout, two-factor authentication, and token validation correctly out of the box.
For API authentication, use JWT Bearer tokens or OAuth 2.0 / OpenID Connect via a standards-compliant library (Microsoft.AspNetCore.Authentication.JwtBearer or Duende IdentityServer).
// Startup / Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!)
),
ClockSkew = TimeSpan.Zero // Removes the default 5-minute tolerance
};
});
builder.Services.AddAuthorization();
Critical: always call UseAuthentication() before UseAuthorization() in your middleware pipeline:
app.UseAuthentication(); // Must come first
app.UseAuthorization(); // Then authorization
Enforce Authorization on Every Endpoint
In ASP.NET Core 8+, use the [Authorize] attribute or policy-based authorization. Enable global authorization as a default and explicitly mark public endpoints:
// Global policy — require auth everywhere by default
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
// Explicitly allow anonymous access where needed
[AllowAnonymous]
[HttpGet("health")]
public IActionResult HealthCheck() => Ok("healthy");
Common mistake: Using [AllowAnonymous] on a controller class and then adding [Authorize] to specific methods. The class-level attribute wins in most scenarios — prefer explicit per-method decoration.
Avoid Custom Authentication Implementations
Rolling your own authentication is one of the most frequent sources of critical vulnerabilities in .NET applications: timing attacks in password comparison, predictable token generation, missing token expiry validation, and insecure password reset flows. Use the framework’s built-in implementations unless you have a specific technical reason not to.
2. SQL Injection Prevention
Use Entity Framework LINQ Queries (Always Parameterized)
Entity Framework LINQ queries are parameterized automatically. This is the recommended approach for all database access:
// SECURE — LINQ query is auto-parameterized by EF Core
var userId = int.Parse(Request.Form["id"]);
var user = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId);
// ALSO SECURE — FromSqlInterpolated uses FormattableString, auto-parameterized
var user = await dbContext.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Id = {userId}")
.FirstOrDefaultAsync();
Never Use FromSqlRaw with Interpolated Strings
FromSqlRaw does NOT parameterize string interpolation — it treats the interpolated string as a raw SQL string:
// VULNERABLE — string interpolation in FromSqlRaw is NOT parameterized
var id = Request.Form["id"];
var user = dbContext.Users
.FromSqlRaw($"SELECT * FROM Users WHERE Id = {id}") // SQL injection
.FirstOrDefault();
// Attacker input: "1 OR 1=1 --" → returns all users
If you need raw SQL, use FromSqlRaw with explicit parameters:
// SECURE — explicit parameters with FromSqlRaw
var user = dbContext.Users
.FromSqlRaw("SELECT * FROM Users WHERE Id = {0}", userId)
.FirstOrDefault();
// OR use FromSqlInterpolated (preferred):
var user = dbContext.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Id = {userId}")
.FirstOrDefault();
Second-Order SQL Injection via EF
A trickier pattern occurs when user input is stored in the database in one request and later retrieved and used in a raw query:
// Request 1: User saves their username (input stored)
await dbContext.Database.ExecuteSqlRawAsync(
"INSERT INTO Profiles (Username) VALUES ({0})", username);
// Request 2: That username is retrieved and used in a raw query later
var profile = dbContext.Database.ExecuteSqlRawAsync(
$"SELECT * FROM Profiles WHERE Username = '{storedUsername}'" // VULNERABLE
);
This is second-order SQL injection — the stored value is trusted as safe when it shouldn’t be. Always parameterize queries that use database-sourced data, not just user-input data. Tools that perform deep taint analysis (like Offensive360 SAST) detect these cross-request taint flows; basic linters and Roslyn analyzers do not.
3. Cross-Site Scripting (XSS) in Razor
Razor Encodes Output by Default
Razor templates automatically HTML-encode values rendered with @. This means basic XSS protection is built in for most rendering scenarios:
@* SECURE — Razor encodes Model.UserInput *@
<p>Hello, @Model.UserInput</p>
@* If UserInput = <script>alert(1)</script> *@
@* Rendered as: <p>Hello, <script>alert(1)</script></p> *@
@Html.Raw() Bypasses Encoding — Use With Extreme Caution
@Html.Raw() tells Razor to render the value without encoding. It should only be used for HTML that you have explicitly constructed yourself:
@* VULNERABLE — user-controlled content rendered without encoding *@
@Html.Raw(Model.UserProvidedDescription)
@* SECURE — only use Html.Raw for HTML you explicitly control *@
@Html.Raw(markdownRenderer.ToHtml(sanitizedMarkdown))
@* Where markdownRenderer is a trusted library that handles its own XSS prevention *@
JavaScript Context in Razor Templates
Razor’s default encoding uses HTML encoding, which is insufficient for JavaScript contexts. Use @Html.JavaScriptStringEncode() for values embedded in script blocks:
@* VULNERABLE — HTML encoding is wrong for a JS string context *@
<script>
var username = '@Model.Username';
// If Username = '; alert(1)// → XSS
</script>
@* SECURE — JavaScript encoding for JS context *@
<script>
var username = '@Html.JavaScriptStringEncode(Model.Username)';
</script>
@* BETTER — move data to a data attribute, avoid inline scripts *@
<div data-username="@Model.Username"></div>
@* Then read from JavaScript: document.querySelector('[data-username]').dataset.username *@
Content Security Policy
Add a Content Security Policy header to restrict the sources of scripts, styles, and other resources. This provides defense-in-depth against XSS — even if a vulnerability exists, the CSP prevents the injected script from executing:
// In Program.cs / Startup middleware
app.Use(async (context, next) =>
{
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' https://cdn.trusted.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.yourcompany.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"frame-ancestors 'none';"
);
await next();
});
4. CORS Configuration
Restrict Allowed Origins to an Explicit List
A common misconfiguration is setting Access-Control-Allow-Origin: * (wildcard) on authenticated API endpoints:
// VULNERABLE — wildcard allows any site to make credentialed requests
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
policy.AllowAnyOrigin() // ← Do not use this with AllowCredentials
.AllowAnyHeader()
.AllowAnyMethod()
);
});
The correct approach is an explicit origin list:
// SECURE — explicit allow list
builder.Services.AddCors(options =>
{
options.AddPolicy("ProductionCors", policy =>
policy.WithOrigins(
"https://app.yourcompany.com",
"https://yourcompany.com",
"https://staging.yourcompany.com"
)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials() // Only safe with explicit origins, not AllowAnyOrigin
);
});
// In your pipeline:
app.UseCors("ProductionCors");
Note: AllowAnyOrigin() and AllowCredentials() cannot be used together in ASP.NET Core — the framework rejects this configuration and throws an exception. However, developers sometimes work around this by using custom CORS middleware with similar substring-matching bugs. See our guide on CORS wildcard parsing off-by-one vulnerabilities for the full pattern catalog.
5. Secrets Management
Never Hardcode Secrets in Source Code
Connection strings, API keys, JWT secrets, and other credentials should never appear in source code or be committed to version control:
// VULNERABLE — secret in source code
private readonly string _jwtSecret = "my-hardcoded-jwt-secret-key-123";
private readonly string _connStr =
"Server=prod-db;Database=App;User=sa;Password=Admin123!";
Use the ASP.NET Core configuration system with environment-specific overrides:
// SECURE — read from configuration (environment variables, Azure Key Vault, etc.)
builder.Services.AddAuthentication(...)
.AddJwtBearer(options =>
{
var secretKey = builder.Configuration["Jwt:SecretKey"]
?? throw new InvalidOperationException("JWT secret key not configured");
options.TokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(secretKey)
)
};
});
Use User Secrets for Development
ASP.NET Core’s User Secrets store development credentials outside the project directory — they’re never committed to source control:
# Initialize user secrets for the project
dotnet user-secrets init --project src/YourApp.Api
# Set a secret
dotnet user-secrets set "Jwt:SecretKey" "your-dev-secret" --project src/YourApp.Api
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;..." --project src/YourApp.Api
For production, use Azure Key Vault (with Microsoft.Extensions.Configuration.AzureKeyVault), AWS Secrets Manager, or environment variables injected by your deployment platform — never appsettings.Production.json committed to source control.
6. Anti-Forgery (CSRF) Protection
Enable Anti-Forgery Globally for MVC Forms
ASP.NET Core includes anti-forgery token validation out of the box for Razor Pages and MVC forms. For minimal APIs or APIs without form-based requests, anti-forgery is less relevant (because APIs use Bearer tokens, not cookie-based sessions).
For MVC controllers with form-based endpoints, validate anti-forgery tokens on all state-changing operations:
// Globally apply ValidateAntiForgeryToken to all POST, PUT, DELETE, PATCH actions
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
});
Or per-controller:
[ValidateAntiForgeryToken]
[HttpPost]
public async Task<IActionResult> UpdateProfile(ProfileViewModel model)
{
// Safe — token validated
}
In Razor forms, @Html.AntiForgeryToken() or asp-antiforgery="true" (default in Tag Helpers) injects the token automatically.
7. Secure Cookie Configuration
Set Secure, HttpOnly, and SameSite Attributes
Authentication cookies must be configured with:
Secure = true— cookie only sent over HTTPSHttpOnly = true— cookie not accessible from JavaScript (prevents cookie theft via XSS)SameSite = StrictorSameSite = Lax— restricts cross-site sending (CSRF defense)
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true; // Not accessible to JavaScript
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HTTPS only
options.Cookie.SameSite = SameSiteMode.Strict; // No cross-site sending
options.Cookie.Name = "__Host-Session"; // __Host- prefix enforces Secure + Path=/
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
});
The __Host- cookie name prefix is a defense-in-depth measure supported by modern browsers: it requires the Secure attribute, a Path=/ value, and no Domain attribute, preventing subdomain hijacking of the cookie.
8. Security Headers
ASP.NET Core doesn’t add security headers by default. Add them in middleware:
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
// Prevent MIME type sniffing
headers.Append("X-Content-Type-Options", "nosniff");
// HSTS — force HTTPS (only effective over HTTPS)
headers.Append("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
// Prevent framing (clickjacking)
headers.Append("X-Frame-Options", "DENY");
// Control referrer information
headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
// Remove version disclosure
headers.Remove("Server");
headers.Remove("X-Powered-By");
await next();
});
For more complex CSP policies, consider the NetEscapades.AspNetCore.SecurityHeaders NuGet package, which provides a fluent API for building security header policies.
9. Rate Limiting (ASP.NET Core 7+)
ASP.NET Core 7 introduced built-in rate limiting middleware. Enable it on authentication endpoints to prevent brute force attacks:
builder.Services.AddRateLimiter(limiterOptions =>
{
// Global rate limit — all endpoints
limiterOptions.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
httpContext => RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}
)
);
// Stricter policy for authentication endpoints
limiterOptions.AddPolicy("AuthPolicy", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5, // 5 login attempts per window
Window = TimeSpan.FromMinutes(15)
}
)
);
});
// Apply the AuthPolicy rate limiter to login endpoints:
app.MapPost("/auth/login", LoginHandler)
.RequireRateLimiting("AuthPolicy");
10. Sensitive Error Handling
Disable Developer Exception Pages in Production
ASP.NET Core’s developer exception page shows full stack traces, source file paths, and environment variables — a significant information disclosure in production:
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
// Generic error handler for production — no stack trace to users
app.UseExceptionHandler("/Error");
app.UseHsts();
}
Log Internally, Show Generic Messages Externally
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionFeature = context.Features.Get<IExceptionHandlerPathFeature>();
// Log the full exception internally
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exceptionFeature?.Error, "Unhandled exception at {Path}", exceptionFeature?.Path);
// Return a generic error to the client — no stack trace
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = "An unexpected error occurred. Please try again.",
requestId = Activity.Current?.Id ?? context.TraceIdentifier
});
});
});
11. Static Analysis (SAST) for ASP.NET Core
Enable Roslyn Security Analyzers
The .NET SDK ships with Microsoft.CodeAnalysis.NetAnalyzers, which includes security rules for the most common ASP.NET Core vulnerability patterns. Enable the full recommended rule set in your .csproj:
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<RunAnalyzersDuringBuild>true</RunAnalyzersDuringBuild>
<!-- Treat these security rules as build errors: -->
<WarningsAsErrors>CA2100;CA3001;CA3002;CA3006;CA3007;CA5350;CA5351;CA5394</WarningsAsErrors>
</PropertyGroup>
dotnet ef + RunAnalyzersDuringBuild
When you run dotnet ef migrations add or dotnet ef database update in a project with TreatWarningsAsErrors=true, the EF design-time build may fail on analyzer warnings. Fix this by passing RunAnalyzersDuringBuild=false only to the EF command:
# Disable analyzers for the EF design-time build only
dotnet ef migrations add MyMigration -- /p:RunAnalyzersDuringBuild=false
dotnet ef database update -- /p:RunAnalyzersDuringBuild=false
Important: Do not set RunAnalyzersDuringBuild=false globally in your .csproj — this disables all security analyzer runs including your main application build. See the full guide on dotnet ef and RunAnalyzersDuringBuild for the complete CI/CD safe pattern.
Beyond Roslyn: Deep Taint Analysis
Roslyn analyzers catch single-method vulnerability patterns — SQL injection or XSS where the user input and the dangerous sink are in the same function. They cannot detect:
- Injection chains that span multiple methods or classes
- Second-order SQL injection (data stored, then later used unsafely)
- Complex SSRF or path traversal chains through service layers
For these patterns, a dedicated SAST tool with interprocedural taint analysis is required. Offensive360 SAST supports ASP.NET Core, ASP.NET Framework, Entity Framework Core, Minimal APIs, and gRPC services with deep C# taint analysis — including the detection of second-order injection patterns that Roslyn cannot see.
ASP.NET Core Security Checklist
Use this as a pre-deployment checklist for ASP.NET Core applications:
Authentication & Authorization:
- ASP.NET Core Identity or standards-based OAuth/OIDC used (no custom auth)
-
UseAuthentication()called beforeUseAuthorization()in middleware pipeline - Global
AuthorizationFallbackPolicyrequires authentication by default - JWT tokens validate
Issuer,Audience,Lifetime, andIssuerSigningKey -
ClockSkewset toTimeSpan.Zeroto prevent extended token validity
SQL Injection:
- All database access uses EF LINQ queries or parameterized raw SQL
- No
FromSqlRaw()with string interpolation -
FromSqlInterpolated()used instead ofFromSqlRaw()when raw SQL is required - ADO.NET queries use
@parametersyntax (no string concatenation)
XSS:
- No
@Html.Raw()with user-controlled content in Razor views - JavaScript contexts use
@Html.JavaScriptStringEncode() - Content-Security-Policy header configured and restrictive
CORS:
- Explicit
AllowOriginslist — neverAllowAnyOrigin()with credentials - CORS policy applied only to endpoints that need cross-origin access
Secrets:
- No hardcoded secrets in source code
- Development secrets use User Secrets (
dotnet user-secrets) - Production secrets use Azure Key Vault or equivalent
-
appsettings.Production.jsondoes not contain credentials and is in.gitignore
Cookies:
-
HttpOnly = trueon session and authentication cookies -
SecurePolicy = CookieSecurePolicy.Always -
SameSite = SameSiteMode.StrictorLax -
__Host-or__Secure-cookie name prefix applied
Security Headers:
-
X-Content-Type-Options: nosniff -
Strict-Transport-SecuritywithincludeSubDomains -
X-Frame-Options: DENY -
ServerandX-Powered-Byheaders removed
Error Handling:
-
UseDeveloperExceptionPage()only inDevelopmentenvironment - Generic error pages for production — no stack traces to clients
- Full exception details logged internally, not returned to users
Rate Limiting:
- Rate limiting on authentication endpoints (
/auth/login,/auth/register,/auth/forgot-password) - Global rate limiting on public API surface
SAST:
- Roslyn security analyzers enabled (
AnalysisLevel=latest-recommended) - Key security rules configured as build errors (
CA2100,CA3001,CA5350) -
dotnet efcommands use-- /p:RunAnalyzersDuringBuild=falseto avoid design-time analyzer conflicts - Full SAST scan (deep taint analysis) run before each release
Frequently Asked Questions
Does ASP.NET Core protect against SQL injection automatically?
Entity Framework LINQ queries are parameterized automatically and are safe by default. However, using FromSqlRaw() with string interpolation bypasses EF’s parameterization and is vulnerable to SQL injection. ADO.NET SqlCommand with string concatenation is also vulnerable. Always use parameterized queries — the framework provides safe patterns, but you can bypass them.
What is the most common security vulnerability in ASP.NET Core applications?
Based on security assessment data across enterprise .NET codebases, the most common high-severity findings are:
- Hardcoded credentials — connection strings, API keys, and JWT secrets committed to source control
- SQL injection via
FromSqlRaw()with interpolated strings — developers using raw SQL when LINQ would be safer - XSS via
@Html.Raw()— developers bypassing Razor’s built-in encoding for rich text display - Missing CORS restrictions —
AllowAnyOrigin()on authenticated API endpoints - Insecure cookie configuration — missing
HttpOnlyorSecureflags on authentication cookies
Is ASP.NET Core safe from CSRF by default?
For Razor Pages and MVC controllers, ASP.NET Core generates and validates anti-forgery tokens automatically when you use Tag Helpers (<form asp-controller="..." asp-action="...">) and apply [ValidateAntiForgeryToken]. However, you must enable this — it is not automatically enforced on all POST endpoints without configuration. For API endpoints using Bearer token authentication, CSRF is less of a concern (since credentials aren’t automatically sent by the browser), but cookie-authenticated APIs still need CSRF protection.
Should I use ASP.NET Core minimal APIs or controllers?
From a security perspective, both minimal APIs and controller-based APIs are equivalent — the security controls (authentication, authorization, anti-forgery, rate limiting) are available in both models. The choice depends on team preference and application complexity. Minimal APIs have less “magic” (no automatic model binding conventions), which can actually reduce the risk of mass assignment vulnerabilities.
How do I prevent path traversal in ASP.NET Core file operations?
Always use Path.GetFullPath() to resolve the full path and verify that the resolved path starts with your intended base directory:
string uploadsDir = Path.GetFullPath("/uploads/");
string filePath = Path.GetFullPath(Path.Combine(uploadsDir, userSuppliedFilename));
if (!filePath.StartsWith(uploadsDir, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Invalid file path");
}
// Now safe to use filePath
Testing Your ASP.NET Core Application Security
After implementing these practices, verify them with automated testing:
-
Run Roslyn security analyzers —
dotnet build /p:RunAnalyzersDuringBuild=true /p:AnalysisLevel=latest-recommended. Fix any security warnings before deploying. -
Run a SAST scan for deep taint analysis — Roslyn analyzers catch single-method patterns. A dedicated SAST tool detects multi-function injection chains, second-order SQL injection, and complex SSRF patterns. Offensive360 offers a one-time ASP.NET Core scan for $500 — results in 48 hours, code never leaves your server.
-
Run a DAST scan against your staging environment — Test your running application for authentication bypass, IDOR, business logic flaws, and runtime misconfigurations that SAST cannot detect from source code alone. See the Offensive360 DAST scanner for authenticated web application testing.
-
Test your CORS configuration — Use the curl-based test commands in our CORS wildcard fix guide to verify your origin validation logic rejects attacker-controlled origins.
-
Scan for hardcoded secrets — Tools like
truffleHogorgitleaksscan your git history for accidentally committed secrets.
Offensive360 performs deep ASP.NET Core SAST with full interprocedural taint analysis — detecting injection chains, second-order SQL injection, and XSS patterns that Roslyn analyzers cannot see. Run a one-time scan for $500 or book a demo.