newsletter

REST API Security Best Practices in ASP.NET Core

10 min read

Newsletter Sponsors

Ask an LLM to write a post for you and you get the same generic opener, the same three points, the same six emojis. Models are stateless β€” they start from zero every time, so they fall back on the average voice of the internet.

This tutorial shows how to fix that by giving an agent memory: past posts stored as vectors, a JSON profile of how you actually write, and a reflection loop that updates that profile as your style changes. All three live in one Oracle AI Database 26ai instance, so there is no pile of separate services to stitch together. The schema is 20 lines, the code is TypeScript end to end, and the full project is on GitHub.

πŸ‘‰ Read the tutorial

AI agents are ideal for tackling well-scoped tasks so you can focus on larger projects, but they can also slow down your workflow. Most run on vendor servers, which means specialized software, API keys, and agent harnesses. Security and governance are outside your control.

Coder Agents is a chat interface and API for delegating research and development to agents inside your Coder deployment, so you can connect models and oversee agents in a single control plane you already trust.

Coder Agents

The process is simple:

  • Describe what you want done in the chat
  • Coder Agents plans, provisions, and executes in the background
  • You review results, steer execution, and refine along the way

Explore Coder Agents and keep your agents from running wild.

It does not matter how clean your architecture is or how fast your queries run - if an attacker can read another user's orders or forge a token, none of that matters.

Most API security problems come from skipping the basics:

  • Not updated NuGet package with security vulnerability
  • Missing validation check
  • Weak authentication setup
  • An over-permissive CORS policy
  • A secret committed to source control.

The good news is that ASP .NET Core gives you almost everything you need built in.

Over the years, I have shipped and reviewed many .NET APIs, and the same set of practices keeps them safe.

Here are 18 of them, each with the code to apply it.

In this post, we will explore:

  • Enforce HTTPS everywhere
  • Authenticate with tokens, not sessions
  • Validate the JWT signature, issuer, audience, and lifetime
  • Authorize with policies, not just [Authorize]
  • Apply the principle of least privilege
  • Validate and sanitize all input
  • Protect against over-posting / mass assignment
  • Validate content types and limit request size
  • Use parameterized queries and EF Core
  • Implement rate limiting and throttling
  • Configure CORS restrictively
  • Return minimal error detail
  • Set security headers
  • Store secrets securely
  • Enforce CSRF protection where relevant
  • Version your API and deprecate insecure endpoints
  • Log and audit security events
  • Keep dependencies patched

Let's dive in.

Copied

1. Enforce HTTPS Everywhere

Every request to your API should travel over an encrypted connection.

Without HTTPS, tokens, passwords, and personal data move in plain text, where anyone on the network path can read them. Plain HTTP also opens the door to downgrade attacks, in which an attacker forces a client to use an insecure connection.

ASP .NET Core gives you two tools. UseHttpsRedirection redirects HTTP requests to HTTPS, and HSTS (HTTP Strict Transport Security) tells browsers to only ever connect over HTTPS:

csharp
var builder = WebApplication.CreateBuilder(args); builder.Services.AddHsts(options => { options.MaxAge = TimeSpan.FromDays(365); options.IncludeSubDomains = true; options.Preload = true; }); var app = builder.Build(); if (!app.Environment.IsDevelopment()) { app.UseHsts(); } app.UseHttpsRedirection();

UseHttpsRedirection upgrades any HTTP request to HTTPS. UseHsts adds the Strict-Transport-Security header, so compliant browsers refuse to talk to your API over plain HTTP at all - which blocks downgrade attacks.

HSTS is skipped during development because you often use http://localhost.

For the full setup, see Configuring HTTPS Redirection and HSTS in ASP .NET Core.

Copied

2. Authenticate with Tokens, Not Sessions

Prefer stateless token authentication over server-side sessions.

A session stores authentication state in server memory or a shared store, which ties each user to a server and makes horizontal scaling harder. A token carries its own proof of identity, so any instance of your API can validate it without a lookup.

For most APIs, that means JWT bearer tokens, often issued by an identity provider through OAuth 2.0 or OpenID Connect:

csharp
builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = "https://your-idp.com"; options.Audience = "shop-api"; }); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization();

The client sends the token in the Authorization: Bearer <token> header on every request. Your API validates it and reads the user's identity from the token's claims - no session store, no sticky sessions, no server-side state to scale.

This keeps your API stateless and easy to scale horizontally.

If you also need to revoke access before a token expires, read How to Implement Refresh Tokens and Token Revocation.

Copied

3. Validate the JWT Signature, Issuer, Audience, and Lifetime

Before accepting a token, you should trust it.

A token is only safe once you verify who issued it, who it is for, that it has not expired, and that its signature is valid. Configure these checks explicitly through TokenValidationParameters rather than trusting framework defaults:

csharp
.AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = "https://your-idp.com", ValidateAudience = true, ValidAudience = "shop-api", ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30) }; });

Each flag closes a hole:

  • ValidateIssuerSigningKey confirms the signature - never turn this off, or anyone can forge a token.
  • ValidateIssuer and ValidateAudience ensure the token came from your identity provider and was meant for your API, not some other service.
  • ValidateLifetime rejects expired tokens.

Pay attention to ClockSkew.

It exists to tolerate small clock differences between servers, but its default is a generous 5 minutes, so an expired token can still be accepted for up to 5 extra minutes. Reducing it to about 30 seconds, or even zero, tightens token expiry enforcement, as long as your servers' clocks are synchronized (with NTP).

See Authentication and Authorization Best Practices for the complete picture.

Copied

4. Authorize with Policies, Not Just [Authorize]

Authentication tells you who the user is. Authorization decides what they are allowed to do.

A bare [Authorize] only checks that the user is logged in. Real rules - "only managers can delete orders", "users can only see their own data" - need policy-based and resource-based authorization.

Define a policy once:

csharp
builder.Services.AddAuthorization(options => { options.AddPolicy("orders:write", policy => policy.RequireRole("Manager") .RequireClaim("permission", "orders:write")); });

Apply it to a controller action:

csharp
[ApiController] [Route("api/orders")] public class OrdersController : ControllerBase { [HttpDelete("{id:int}")] [Authorize(Policy = "orders:write")] public IActionResult Delete(int id) { _orderService.Delete(id); return NoContent(); } }

Or on a minimal API endpoint:

csharp
app.MapDelete("/api/orders/{id:int}", (int id, IOrderService orders) => { orders.Delete(id); return Results.NoContent(); }) .RequireAuthorization("orders:write");

When the rule depends on the specific resource - like "only the owner can edit this order" - use resource-based authorization with IAuthorizationService.AuthorizeAsync(user, order, "OrderOwner"), which evaluates the policy against the actual entity.

Policies keep authorization logic in one place, out of your endpoint bodies.

Copied

5. Apply the Principle of Least Privilege

Give every client the minimum access it needs, and nothing more.

A token, a claim set, or an API key should grant exactly the permissions required for its job. A mobile app that only reads the product catalog should not hold a token capable of deleting orders.

Scope this at the token level. When your identity provider issues a token, it includes only the scopes that the client was granted, and your policies check for them:

csharp
builder.Services.AddAuthorization(options => { options.AddPolicy("catalog:read", policy => policy.RequireClaim("scope", "catalog:read")); options.AddPolicy("orders:write", policy => policy.RequireClaim("scope", "orders:write")); });

The same idea applies to API keys and database accounts - scope them down too. If a credential leaks, least privilege limits the blast radius to only what that one credential could do.

Copied

6. Validate and Sanitize All Input

Treat every incoming payload as dangerous until proven valid.

Malformed or malicious input should be rejected at the edge, before it reaches your business logic or your database. ASP .NET Core helps here: with the [ApiController] attribute, a request that fails model validation automatically returns a 400 with a problem-details body.

Add data annotations or, for richer rules, FluentValidation:

csharp
public class CreateProductRequest { [Required] [StringLength(200, MinimumLength = 1)] public string Name { get; set; } = string.Empty; [Range(0.01, 1_000_000)] public decimal Price { get; set; } } [ApiController] [Route("api/products")] public class ProductsController : ControllerBase { [HttpPost] public IActionResult Create(CreateProductRequest request) { // If we get here, the model is already valid var product = _productService.Create(request); return CreatedAtAction(nameof(Get), new { id = product.Id }, product); } }

The minimal API equivalent runs validation through a filter or explicitly:

csharp
app.MapPost("/api/products", (CreateProductRequest request, IValidator<CreateProductRequest> validator) => { var result = validator.Validate(request); if (!result.IsValid) { return Results.ValidationProblem(result.ToDictionary()); } var product = productService.Create(request); return Results.Created($"/api/products/{product.Id}", product); });

Validating early turns a class of attacks - oversized strings, out-of-range numbers, missing fields - into a clean 400 instead of an exception deep in your code.

Copied

7. Protect Against Over-Posting / Mass Assignment

Never bind incoming JSON straight onto your database entity.

If clients post to your EF Core entity directly, they can set fields they should never control - IsAdmin, Balance, Status, or another user's Id. This is called over-posting, or mass assignment.

Bind to a dedicated request DTO that exposes only the fields a client is allowed to set, then map it onto the entity yourself:

csharp
// Request DTO - only what the client may set public record UpdateProductRequest(string Name, decimal Price); [HttpPut("{id:int}")] public async Task<IActionResult> Update(int id, UpdateProductRequest request) { var product = await _dbContext.Products.FindAsync(id); if (product is null) { return NotFound(); } // Map only the allowed fields product.Name = request.Name; product.Price = request.Price; await _dbContext.SaveChangesAsync(); return NoContent(); }

The Product entity might also have CreatedAt, OwnerId, or IsFeatured fields, but because the client can only send Name and Price, those fields are inaccessible from the outside.

Separate request models require a small amount of typing and close a whole category of privilege-escalation bugs.

Copied

8. Validate Content Types and Limit Request Size

Reject what you are not prepared to handle.

An endpoint that accepts JSON should reject other media types, and no endpoint should accept an unbounded request body - a multi-gigabyte upload is an easy way to exhaust server memory and take your API down.

Restrict the content type with [Consumes] and cap the body size with [RequestSizeLimit]:

csharp
[HttpPost] [Consumes("application/json")] [RequestSizeLimit(1_000_000)] // 1 MB public IActionResult Create(CreateProductRequest request) { var product = _productService.Create(request); return CreatedAtAction(nameof(Get), new { id = product.Id }, product); }

You can also set a global limit through Kestrel:

csharp
builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 1_000_000; });

The same discipline applies to the amount of data a client can pull back.

Always enforce pagination with a maximum page size on collection endpoints, so a client cannot request an unbounded result set:

csharp
[HttpGet] public IActionResult GetProducts(int page = 1, int pageSize = 20) { // Clamp the page size so no one can ask for everything at once pageSize = Math.Min(pageSize, 100); var products = _productService.GetPage(page, pageSize); return Ok(products); }

Capping request size, content type, and page size all defend against the same thing: a single request that tries to consume more resources than your server can spare.

Copied

9. Use Parameterized Queries and EF Core

Never build SQL by concatenating strings together from user input.

String concatenation is how SQL injection happens - a crafted input like '; DROP TABLE Products; -- becomes part of your query. Parameterized queries keep user input as data, never as executable SQL.

EF Core parameterizes everything by default, so a LINQ query is always safe:

csharp
// 1. Safe - EF Core parameterizes 'search' var products = await _dbContext.Products .Where(p => p.Name.Contains(search)) .ToListAsync();

When you do need raw SQL, use FromSql orFromSqlInterpolated, which turn the interpolated values into parameters rather than literal text:

csharp
// 2. Safe - 'category' becomes a SQL parameter, not concatenated text var products = await _dbContext.Products .FromSqlInterpolated($"SELECT * FROM Products WHERE Category = {category}") .ToListAsync(); // 3. Dangerous - never do this var sql = "SELECT * FROM Products WHERE Category = '" + category + "'"; // 4. And never use FromSqlRaw with string interpolation var products = await _dbContext.Products .FromSqlRaw($"SELECT * FROM Products WHERE Category = {category}") .ToListAsync();

The first two are safe; 3 and 4 are an attacker's door.

Copied

10. Implement Rate Limiting and Throttling

Limit how often a single client can call your API.

Without limits, one client - or one attacker - can hammer your login endpoint with brute-force attempts, or flood your API with requests until it falls over. ASP .NET Core has a built-in rate limiter you wire up in Program.cs:

csharp
builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter("api", limiter => { limiter.PermitLimit = 10; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 0; }); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); var app = builder.Build(); app.UseRateLimiter();

Then apply the policy to a controller action or a minimal API endpoint:

csharp
[HttpPost("login")] [EnableRateLimiting("api")] public IActionResult Login(LoginRequest request) { /* ... */ }
csharp
app.MapPost("/login", (LoginRequest request) => { /* ... */ }) .RequireRateLimiting("api");

When a client exceeds the limit, it gets a 429 Too Many Requests instead of reaching your code. This blunts brute-force and denial-of-service abuse.

Copied

11. Configure CORS Restrictively

Allow only the origins you trust to call your API from a browser.

Cross-Origin Resource Sharing (CORS) controls which web origins can make requests to your API. The dangerous shortcut is AllowAnyOrigin(), which lets any website on the internet call your API on behalf of a logged-in user.

Define a named policy that lists exactly the origins, methods, and headers you allow:

csharp
builder.Services.AddCors(options => { options.AddPolicy("Shop", policy => policy.WithOrigins("https://shop.example.com") .WithMethods("GET", "POST", "PUT", "DELETE") .WithHeaders("Authorization", "Content-Type")); }); var app = builder.Build(); app.UseCors("Shop");

This says: only https://shop.example.com may call us, only with these methods, only with these headers. Everything else is refused.

Reserve AllowAnyOrigin for truly public, unauthenticated APIs - and never combine it with credentials.

For the full guide, see CORS in ASP .NET Core.

Copied

12. Return Minimal Error Detail

An error response should help the caller, not the attacker.

A raw exception page leaks stack traces, framework versions, file paths, and SQL - a map of your internals. Return a clean, standard error instead, using Problem Details (RFC 9457):

csharp
builder.Services.AddProblemDetails(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { // Production: a clean ProblemDetails response, no stack trace app.UseExceptionHandler(); }

In production, an unhandled exception returns a structured Problem Details body with a status code and a generic message - and nothing about your internals.

Keep detailed diagnostics in your logs, where only you can see them, not in the HTTP response, where a client can.

You can read more about Problem Details in Best Practices for Building REST APIs.

Copied

13. Set Security Headers

A few response headers harden your API against common browser-side attacks.

Headers like Content-Security-Policy, X-Content-Type-Options, and Referrer-Policy tell the browser how to treat your responses - which scripts may run, whether to guess content types, how much referrer information to leak.

Add them with a small piece of middleware that runs on every response:

csharp
app.Use(async (context, next) => { var headers = context.Response.Headers; headers["X-Content-Type-Options"] = "nosniff"; headers["Referrer-Policy"] = "no-referrer"; headers["Content-Security-Policy"] = "default-src 'self'"; headers["X-Frame-Options"] = "DENY"; await next(); });
  • X-Content-Type-Options: nosniff stops the browser from guessing (and mis-running) content types.
  • Content-Security-Policy restricts where scripts, styles, and other resources may load from.
  • Referrer-Policy controls how much of your URL is sent to other sites.
  • X-Frame-Options: DENY prevents your responses from being embedded in a frame (clickjacking).

For a pure JSON API, these matter most when responses are rendered in a browser, but they are cheap insurance for any API.

Note: in production, consider using a maintained library or a reverse proxy to manage these headers and a stricter Content-Security-Policy tailored to your app.

Copied

14. Store Secrets Securely

Keys, connection strings, and tokens must never live in source control.

A secret committed to appsettings.json is a secret leaked - it stays in your Git history forever, visible to everyone with repository access. Also, when using AI Agents, they can directly read your secrets from your configuration, unless you deny access for them. Be aware of this!

Keep secrets out of the codebase entirely.

In development, use the .NET Secret Manager:

bash
dotnet user-secrets init dotnet user-secrets set "ConnectionStrings:Shop" "Server=...;Password=..."

In production, use environment variables or a managed secret store like Azure Key Vault:

csharp
builder.Configuration.AddAzureKeyVault( new Uri("https://shop-vault.vault.azure.net/"), new DefaultAzureCredential()); var connectionString = builder.Configuration.GetConnectionString("Shop");

Your code then reads the configuration the same way, regardless of where the value came from.

The application does not care about the source - only that the value is not sitting in a committed file.

Copied

15. Enforce CSRF Protection Where Relevant

Cross-Site Request Forgery protection matters when you authenticate with cookies.

CSRF tricks a logged-in user's browser into sending an unwanted request, using the cookie the browser attaches automatically. If your API authenticates with cookies, you need anti-forgery tokens:

csharp
builder.Services.AddAntiforgery(options => { options.HeaderName = "X-CSRF-TOKEN"; }); var app = builder.Build(); app.UseAntiforgery();

Then require a valid token on state-changing endpoints:

csharp
[HttpPost] [ValidateAntiForgeryToken] public IActionResult Create(CreateOrderRequest request) { /* ... */ }

Here is the important nuance: pure token-based APIs are largely immune to CSRF.

If your client sends a JWT in the Authorization header (not a cookie), the browser does not automatically attach it, so a forged cross-site request carries no credentials. You mainly need antiforgery for cookie-authenticated endpoints - server-rendered apps and APIs that use cookie auth.

Match the protection to your authentication model: cookies need CSRF defense, bearer tokens generally do not.

Copied

16. Version Your API and Deprecate Insecure Endpoints

Versioning lets you retire a bad design without breaking every client at once.

When an endpoint turns out to be insecure or poorly shaped, you need a way to introduce a fixed version and phase out the old one on a schedule. API versioning gives you that path.

Add the Asp.Versioning.Mvc package and mark the old version deprecated:

csharp
builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(2, 0); options.ReportApiVersions = true; });
csharp
[ApiController] [ApiVersion("1.0", Deprecated = true)] [ApiVersion("2.0")] [Route("api/v{version:apiVersion}/orders")] public class OrdersController : ControllerBase { // v2 fixes the insecure contract; v1 is marked deprecated }

ReportApiVersions adds api-supported-versions and api-deprecated-versions headers to your responses, so clients see the deprecation coming and can migrate before you remove v1.

This turns "we cannot fix it because clients depend on it" into a managed migration.

Copied

17. Log and Audit Security Events

Security events - failed logins, authorization denials, suspicious request patterns - need to be logged with enough context to reconstruct what happened. Capture them as structured logs:

csharp
[HttpPost("login")] public async Task<IActionResult> Login(LoginRequest request) { var result = await _authService.AuthenticateAsync(request); if (!result.Succeeded) { _logger.LogWarning( "Failed login for {Email} from {IpAddress}", request.Email, HttpContext.Connection.RemoteIpAddress); return Unauthorized(); } return Ok(result.Token); }

Log authentication failures, authorization denials, and anything that appears to be probing.

Record enough context to support a real forensic investigation - which can include sensitive details where your threat model requires them, such as the account, the source IP, and the action attempted.

Because those logs can contain sensitive data, treat them as sensitive: store them where only authorized people can read them, and apply retention and access controls.

For setup, see Logging Best Practices in ASP .NET Core.

Copied

18. Keep Dependencies Patched

Most breaches exploit known vulnerabilities, not new ones.

Your API depends on dozens of NuGet packages and the .NET runtime, and each is a potential entry point when a security fix ships and you have not applied it. Staying current is one of the highest-value things you can do.

.NET can list vulnerable packages for you:

bash
dotnet list package --vulnerable dotnet list package --outdated

Run these regularly, and wire the scan into your CI pipeline so a known-vulnerable dependency fails the build:

bash
# Include transitive dependencies - a vulnerability three packages deep is still yours dotnet list package --vulnerable --include-transitive

Update your NuGet packages and the .NET runtime on a schedule, not only when something breaks. Combined with a tool like Dependabot or GitHub security alerts, this closes the gap attackers rely on most.

Copied

Summary

Security is not a single feature you add at the end. It is a set of habits applied across the whole API.

Let's recap the key takeaways:

  • Secure the connection. Enforce HTTPS with redirection and HSTS, and add security headers so responses are hard to abuse.
  • Get authentication and authorization right. Use stateless tokens, validate every part of the JWT (including a tight ClockSkew), and authorize with policies and least privilege - not just [Authorize].
  • Treat all input as hostile. Validate and sanitize payloads, bind to request DTOs to stop over-posting, cap request and page size, and let EF Core parameterize your queries.
  • Defend against abuse. Rate-limit clients, restrict CORS to trusted origins, and apply CSRF protection wherever you use cookie authentication.
  • Do not leak information. Return minimal error detail with Problem Details, and keep secrets in a vault or environment, never in source control.
  • Operate securely over time. Version and deprecate insecure endpoints, log and audit security events into access-controlled storage, and keep your dependencies patched.

No single practice makes an API secure, and you do not have to apply all 18 at once.

Start with the ones that close your biggest gaps - usually HTTPS, token validation, authorization, and input validation - then work down the list.


I am building a deep-dive System Design course.

It will be better than anything you have seen so far:

  • Reading
  • Watching videos
  • Designing real systems in the browser
  • Taking tests

Join the waitlist here to get early access and a launch discount.


Hope you find this newsletter useful. See you next time.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design β€” answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
View the Playbook

Not sure where you stand? Take the free .NET Interview Run:

  • Find out your real level β€” Junior to Senior+
  • A realistic mock .NET interview β€” across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next β€” the perfect way to benchmark yourself before diving into the Playbook.

Start your free run

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.