Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Application Security

ASP.NET Core Security Best Practices 2026

ASP.NET Core security best practices: auth middleware, CORS policy, SQL injection via EF Core, XSS in Razor, secrets management and CI/CD SAST integration.

Offensive360 Security Research Team — min read
ASP.NET Core security ASP.NET security best practices .NET security C# security ASP.NET Core SAST .NET application security Entity Framework security Razor XSS dotnet security web application security .NET

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, &lt;script&gt;alert(1)&lt;/script&gt;</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.


Set Secure, HttpOnly, and SameSite Attributes

Authentication cookies must be configured with:

  • Secure = true — cookie only sent over HTTPS
  • HttpOnly = true — cookie not accessible from JavaScript (prevents cookie theft via XSS)
  • SameSite = Strict or SameSite = 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 before UseAuthorization() in middleware pipeline
  • Global AuthorizationFallbackPolicy requires authentication by default
  • JWT tokens validate Issuer, Audience, Lifetime, and IssuerSigningKey
  • ClockSkew set to TimeSpan.Zero to 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 of FromSqlRaw() when raw SQL is required
  • ADO.NET queries use @parameter syntax (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 AllowOrigins list — never AllowAnyOrigin() 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.json does not contain credentials and is in .gitignore

Cookies:

  • HttpOnly = true on session and authentication cookies
  • SecurePolicy = CookieSecurePolicy.Always
  • SameSite = SameSiteMode.Strict or Lax
  • __Host- or __Secure- cookie name prefix applied

Security Headers:

  • X-Content-Type-Options: nosniff
  • Strict-Transport-Security with includeSubDomains
  • X-Frame-Options: DENY
  • Server and X-Powered-By headers removed

Error Handling:

  • UseDeveloperExceptionPage() only in Development environment
  • 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 ef commands use -- /p:RunAnalyzersDuringBuild=false to 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:

  1. Hardcoded credentials — connection strings, API keys, and JWT secrets committed to source control
  2. SQL injection via FromSqlRaw() with interpolated strings — developers using raw SQL when LINQ would be safer
  3. XSS via @Html.Raw() — developers bypassing Razor’s built-in encoding for rich text display
  4. Missing CORS restrictionsAllowAnyOrigin() on authenticated API endpoints
  5. Insecure cookie configuration — missing HttpOnly or Secure flags 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:

  1. Run Roslyn security analyzersdotnet build /p:RunAnalyzersDuringBuild=true /p:AnalysisLevel=latest-recommended. Fix any security warnings before deploying.

  2. 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.

  3. 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.

  4. 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.

  5. Scan for hardcoded secrets — Tools like truffleHog or gitleaks scan 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.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.