Skip to main content

API Security Best Practices 2026: Secure REST APIs in C#

Learn API security best practices for 2026 with runnable C# and ASP.NET Core examples — JWT, rate limiting, BOLA fixes. Start hardening your REST API today.

If you ship a REST API in 2026, you are running a public attack surface. Attackers no longer bother with your UI — they read your OpenAPI document, enumerate your endpoints, and hammer them directly. This guide covers the API security best practices that actually matter today, with runnable C# and ASP.NET Core examples you can drop into a real project. We will focus on the failures that show up in genuine breach reports: broken object-level authorization, weak token validation, missing rate limits, and secrets sitting in source control.

Every example targets ASP.NET Core on .NET 8 or later, and each one explains why the control exists — because a security rule you do not understand is a security rule you will disable the first time it breaks a deployment.

Why REST API Security Fails: The OWASP API Security Top 10

The OWASP API Security Top 10 is the reference list every security reviewer works from, and the ranking is instructive. The top three risks are all authorization problems, not cryptography problems:

  • API1 — Broken Object Level Authorization (BOLA/IDOR): a logged-in user requests someone else's record and the API hands it over.
  • API2 — Broken Authentication: tokens that are not validated properly, or that never expire.
  • API3 — Broken Object Property Level Authorization: mass assignment and over-posting, plus leaking fields the caller should never see.
  • API4 — Unrestricted Resource Consumption: no rate limits, no payload caps, no pagination ceilings.
  • API5 — Broken Function Level Authorization: an admin endpoint that only checks "is authenticated".

Notice what is missing: SQL injection is far down the list, and TLS misconfiguration barely registers. Modern frameworks solved those for you. What frameworks cannot solve is business logic — only you know that order #4821 belongs to customer #93. That is why authorization dominates this article.

1. Authenticate Properly: JWT Validation in ASP.NET Core

The single most common authentication bug is accepting a token without fully validating it. A JWT is just base64 — anyone can craft one. Validation is what makes it trustworthy, and the defaults are not enough on their own.

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Authority makes the app fetch signing keys from the identity provider
        // and rotate them automatically. Never hard-code a symmetric secret.
        options.Authority = builder.Configuration["Auth:Authority"];
        options.RequireHttpsMetadata = true;

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Auth:Authority"],

            ValidateAudience = true,          // stops token replay across services
            ValidAudience = "api://orders",

            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30),   // default is 5 minutes — too generous

            ValidateIssuerSigningKey = true,
            ValidAlgorithms = new[] { "RS256" }     // block the "alg: none" family
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

Why each line matters. ValidateAudience stops a token issued for your marketing API from being replayed against your payments API. Restricting ValidAlgorithms to asymmetric RS256 removes an entire class of algorithm-confusion attacks. And cutting ClockSkew from the five-minute default to 30 seconds means a revoked short-lived token really does die in seconds.

Common pitfall: using long-lived access tokens "because refresh is annoying". Keep access tokens at 5–15 minutes and use refresh tokens with rotation and reuse detection. A stolen 15-minute token is an incident; a stolen 30-day token is a breach.

2. Fix Broken Object Level Authorization (The #1 API Risk)

Here is the bug that appears in almost every penetration test report. It looks completely innocent:

// VULNERABLE — authenticated, but not authorized
app.MapGet("/api/orders/{id:guid}", async (Guid id, AppDbContext db) =>
{
    var order = await db.Orders.FindAsync(id);
    return order is null ? Results.NotFound() : Results.Ok(order);
})
.RequireAuthorization();

The endpoint requires a valid token, so it feels safe. But any authenticated user can iterate GUIDs — or, worse, read IDs from a list endpoint — and pull every order in the database. Authentication answers "who are you"; it never answers "may you see this specific row".

The fix is to make ownership part of the query, not a separate check that a future refactor can delete:

app.MapGet("/api/orders/{id:guid}", async (
    Guid id,
    ClaimsPrincipal user,
    AppDbContext db,
    CancellationToken ct) =>
{
    var customerId = user.FindFirstValue("customer_id");
    if (customerId is null) return Results.Forbid();

    var order = await db.Orders
        .Where(o => o.Id == id && o.CustomerId == customerId)  // ownership in the WHERE clause
        .Select(o => new OrderResponse(o.Id, o.Total, o.Status)) // projection, not the entity
        .SingleOrDefaultAsync(ct);

    // Return 404, not 403: a 403 confirms the record exists.
    return order is null ? Results.NotFound() : Results.Ok(order);
})
.RequireAuthorization();

Two subtleties worth internalising. First, returning 404 instead of 403 prevents resource enumeration — a 403 tells the attacker the ID is real. Second, the Select projection means you physically cannot leak InternalNotes or CostPrice, even if someone adds those columns later.

For anything more complex than owner checks, use resource-based authorization handlers so the policy lives in one testable place:

public class OrderOwnerHandler : AuthorizationHandler<SameOwnerRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        SameOwnerRequirement requirement,
        Order resource)
    {
        var customerId = context.User.FindFirstValue("customer_id");

        if (customerId is not null && resource.CustomerId == customerId)
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

3. API Security Best Practices for Input: Validation and Mass Assignment

Binding request JSON straight onto an EF Core entity is convenient and dangerous. If your Order entity has an IsPaid or DiscountPercent property, a caller can simply include it in the payload.

// Explicit request contract — the client cannot set what is not here.
public record CreateOrderRequest(string Sku, int Quantity, string ShippingPostcode);

app.MapPost("/api/orders", async (
    CreateOrderRequest request,
    ClaimsPrincipal user,
    AppDbContext db,
    CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(request.Sku) || request.Sku.Length > 32)
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["sku"] = ["SKU is required and must be 32 characters or fewer."]
        });

    if (request.Quantity is < 1 or > 100)
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["quantity"] = ["Quantity must be between 1 and 100."]
        });

    var order = new Order
    {
        Id = Guid.CreateVersion7(),
        CustomerId = user.FindFirstValue("customer_id")!,  // from the token, never the body
        Sku = request.Sku,
        Quantity = request.Quantity,
        Status = OrderStatus.Pending                        // server decides state
    };

    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);
    return Results.Created($"/api/orders/{order.Id}", new OrderResponse(order.Id, 0m, order.Status));
})
.RequireAuthorization();

The rule: allow-list what the client may send, and derive identity and state on the server. Any value that affects money, permissions, or ownership must come from the token or your own logic — never from the request body.

Also cap request sizes. An unbounded JSON body is a cheap denial-of-service vector, and deeply nested JSON can exhaust the parser:

builder.Services.Configure<JsonOptions>(o => o.SerializerOptions.MaxDepth = 16);
builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 256 * 1024); // 256 KB

4. Rate Limiting: Stop Credential Stuffing and Scraping

ASP.NET Core ships first-class rate limiting middleware, so there is no excuse for leaving endpoints uncapped. Partition by user where you can and by IP where you cannot — a global limit is trivially exhausted by one abusive client.

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(http =>
    {
        var partitionKey = http.User.FindFirstValue(ClaimTypes.NameIdentifier)
                           ?? http.Connection.RemoteIpAddress?.ToString()
                           ?? "anonymous";

        return RateLimitPartition.GetTokenBucketLimiter(partitionKey, _ =>
            new TokenBucketRateLimiterOptions
            {
                TokenLimit = 100,                             // burst allowance
                TokensPerPeriod = 20,
                ReplenishmentPeriod = TimeSpan.FromSeconds(10),
                QueueLimit = 0                                // fail fast, do not queue
            });
    });

    // Much tighter budget for the endpoint attackers care about most.
    options.AddFixedWindowLimiter("login", o =>
    {
        o.PermitLimit = 5;
        o.Window = TimeSpan.FromMinutes(1);
        o.QueueLimit = 0;
    });
});

app.UseRateLimiter();
app.MapPost("/api/auth/login", LoginHandler).RequireRateLimiting("login");

A token bucket suits normal API traffic because it tolerates legitimate bursts while capping sustained throughput. A strict fixed window suits login, password reset, and OTP endpoints, where bursts are almost always malicious. Pair this with pagination ceilings — reject ?pageSize=100000 rather than serving it.

5. Secrets, Transport, and Response Headers

Connection strings and signing keys do not belong in appsettings.json. Use managed identity so no credential ever exists in your code or CI variables:

builder.Configuration.AddAzureKeyVault(
    new Uri($"https://{builder.Configuration["KeyVault:Name"]}.vault.azure.net/"),
    new DefaultAzureCredential());

Then lock down transport and headers. HSTS forces HTTPS at the browser level, and a restrictive CORS policy stops hostile origins from calling your API with a victim's cookies:

builder.Services.AddCors(o => o.AddPolicy("app", p => p
    .WithOrigins("https://app.example.com")   // never AllowAnyOrigin with credentials
    .WithMethods("GET", "POST", "PUT", "DELETE")
    .AllowCredentials()));

app.UseHsts();
app.UseHttpsRedirection();
app.UseCors("app");

app.Use(async (context, next) =>
{
    var headers = context.Response.Headers;
    headers["X-Content-Type-Options"] = "nosniff";
    headers["Referrer-Policy"] = "no-referrer";
    headers["Cache-Control"] = "no-store";          // keeps tokens out of shared caches
    headers.Remove("Server");
    await next();
});

Finally, never let raw exceptions reach the client. Stack traces expose framework versions, file paths, and table names. Use app.UseExceptionHandler() with a ProblemDetails response, and log the detail server-side with the correlation ID only.

6. Log What Matters, Leak Nothing

Detection is part of security. Log authentication failures, authorization denials, and 429s with enough structure to alert on — but scrub tokens, passwords, and PII before they hit your log sink. A practical rule: log the decision and the subject ID, never the credential.

logger.LogWarning(
    "Authorization denied {Subject} {Resource} {CorrelationId}",
    user.FindFirstValue(ClaimTypes.NameIdentifier),
    $"order:{id}",
    context.TraceIdentifier);

Three consecutive BOLA denials from one account is a probe in progress. Without this log line, you find out from a customer.

Common Pitfalls to Avoid

  • Treating an API key as authentication. API keys identify an application, not a user. They belong in a header, rotated regularly, and never in a query string (query strings land in logs and proxies).
  • Publishing a fully public Swagger UI in production. It is a free endpoint map. Gate it behind authentication or ship it internally only.
  • Relying on obscure GUIDs for security. GUIDs are not authorization. Sequential IDs with a correct ownership check are safer than GUIDs without one.
  • Validating on the client only. Every client-side check is advisory; attackers call your API with curl.
  • Forgetting the 3rd-party supply chain. Run dotnet list package --vulnerable --include-transitive in CI and fail the build on known CVEs.

Conclusion: Key Takeaways

The API security best practices that prevent real breaches are unglamorous and mostly free. If you take away five things from this guide:

  • Authorize every object, every time. Put ownership in the query, return 404 rather than 403, and project to DTOs so leaks are structurally impossible.
  • Validate tokens strictly. Check issuer, audience, lifetime, and algorithm; keep access tokens short and rotate refresh tokens.
  • Allow-list your inputs. Explicit request records kill mass assignment, and body-size plus depth limits kill cheap DoS.
  • Rate limit per user or IP. Token bucket for general traffic, tight fixed window for login and reset endpoints.
  • Keep secrets out of code and detail out of responses. Managed identity in, stack traces out, structured security logs everywhere.

Start with object-level authorization — it is the top OWASP risk for a reason, and it is almost certainly the gap in your codebase right now. Audit one controller today: for every endpoint that takes an ID, ask whether the current user is provably allowed to touch that specific record. If the answer is "the token was valid", you have work to do.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...