Security

Building Secure REST APIs in ASP.NET Core: JWT, Rate Limiting and Versioning

Building Secure REST APIs in ASP.NET Core: JWT, Rate Limiting and Versioning

An API is the front door to your business data, and in a security review it's usually where I find the most serious issues. They're rarely exotic. They are missing authorisation checks, tokens that are never validated properly, endpoints that return far more data than the client needs, and no protection against someone calling the API ten thousand times a minute. This guide walks through securing an ASP.NET Core Web API on .NET 10, layer by layer, with code you can drop into a real project.

1. HTTPS everywhere, and sensible headers

Serve the API over HTTPS only and enable HSTS in production. If you're behind a reverse proxy or load balancer, configure forwarded headers so ASP.NET Core knows the original scheme and client IP:

builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
    o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    // Add KnownProxies / KnownNetworks for your load balancer
});

var app = builder.Build();
app.UseForwardedHeaders();
if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}
app.UseHttpsRedirection();

2. Authentication with JWT bearer tokens

For most APIs, let a proper identity provider issue tokens (Microsoft Entra ID, Entra External ID, Auth0, Keycloak, or Duende IdentityServer) and have the API validate them. Don't build your own token server unless you have a very good reason.

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];   // e.g. https://login.microsoftonline.com/{tenant}/v2.0
        options.Audience  = builder.Configuration["Auth:Audience"];    // your API's app ID URI
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(1)
        };
        options.MapInboundClaims = false;   // keep claim names as issued ("sub", "roles")
    });

Setting Authority makes the handler download the provider's signing keys and validate signatures automatically. Keep access tokens short-lived (5 to 60 minutes) and use refresh tokens in the client. For browser single-page apps, the safest pattern today is a backend-for-frontend (BFF) that keeps tokens on the server and gives the browser an HTTP-only cookie.

3. Authorisation: deny by default

Authentication tells you who the caller is; authorisation decides what they may do. Make every endpoint require an authenticated user unless you explicitly opt out:

builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("orders:write", p => p.RequireClaim("scope", "orders.write"))
    .AddPolicy("admin", p => p.RequireRole("Admin"));

app.MapPost("/api/orders", CreateOrder).RequireAuthorization("orders:write");
app.MapGet("/api/health", () => Results.Ok()).AllowAnonymous();

Policies and roles stop the wrong kind of user. They don't stop user A reading user B's invoice by changing an ID in the URL. That flaw, Broken Object Level Authorisation, is number one on the OWASP API Security Top 10. Check ownership on every request that loads a specific resource:

app.MapGet("/api/invoices/{id:guid}", async (Guid id, ClaimsPrincipal user, AppDbContext db) =>
{
    var userId = user.FindFirstValue("sub");
    var invoice = await db.Invoices.AsNoTracking()
        .Where(i => i.Id == id && i.CustomerUserId == userId)   // ownership in the query
        .Select(i => new InvoiceDto(i.Id, i.Number, i.Total, i.IssuedAt))
        .FirstOrDefaultAsync();

    return invoice is null ? Results.NotFound() : Results.Ok(invoice);
});

Return 404 rather than 403 for resources the caller doesn't own, so you don't confirm that the ID exists. In multi-tenant systems, an EF Core global query filter on TenantId adds a second safety net.

4. Validate input and control output

  • Validate every request model. .NET 10 adds built-in validation support for Minimal APIs (builder.Services.AddValidation()) using data annotations; FluentValidation is a good choice for complex rules.
  • Never bind directly to entities. Accept a request DTO with only the fields a client may set. Binding to an entity invites mass assignment (a client sends "isAdmin": true and it gets saved).
  • Return response DTOs, not entities, so you never leak password hashes, internal flags or other customers' data through a navigation property.
  • Limit sizes: maximum request body size, maximum page size for lists, and maximum length for strings.
  • Use parameterised queries. EF Core and Dapper parameterise for you; be careful with FromSqlRaw and string concatenation.

5. Rate limiting

ASP.NET Core has rate limiting built in. Use it to protect login endpoints from brute force, expensive endpoints from abuse, and your whole API from accidental client loops:

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

    // Per user (or per IP for anonymous callers): 100 requests per minute
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: ctx.User.FindFirstValue("sub") ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "anon",
            factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromMinutes(1) }));

    // Stricter policy for authentication endpoints
    options.AddSlidingWindowLimiter("auth", o =>
    {
        o.PermitLimit = 5;
        o.Window = TimeSpan.FromMinutes(1);
        o.SegmentsPerWindow = 6;
    });
});

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

The built-in limiter is per instance. If you run several instances, also rate limit at the edge (Azure Front Door, API Management or your gateway) or use a distributed limiter backed by Redis.

6. Versioning without breaking clients

Once mobile apps and partners depend on your API, you can't change a contract without warning. Version from day one with the Asp.Versioning.Http package:

builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1, 0);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;                       // adds api-supported-versions header
    o.ApiVersionReader = new UrlSegmentApiVersionReader();
});

var versions = app.NewApiVersionSet().HasApiVersion(new ApiVersion(1, 0)).HasApiVersion(new ApiVersion(2, 0)).Build();
var orders = app.MapGroup("/api/v{version:apiVersion}/orders").WithApiVersionSet(versions);

orders.MapGet("/", GetOrdersV1).MapToApiVersion(new ApiVersion(1, 0));
orders.MapGet("/", GetOrdersV2).MapToApiVersion(new ApiVersion(2, 0));

Additive changes (new optional fields, new endpoints) don't need a new version. Removing or renaming fields, or changing their meaning, does. Announce deprecations with a date and the Sunset header.

7. Safe error handling

Stack traces and SQL errors in API responses are a gift to attackers. Return standard Problem Details responses and log the details server-side with a correlation ID:

builder.Services.AddProblemDetails();
app.UseExceptionHandler();       // converts unhandled exceptions to RFC 9457 problem details
app.UseStatusCodePages();

8. Secrets, CORS and the rest of the checklist

  • Secrets never live in source control or appsettings.json. Use user-secrets locally and Azure Key Vault or environment variables in production. Better still, use managed identities so there's no secret at all.
  • CORS: allow only the specific origins that need it. AllowAnyOrigin together with credentials is a bug, not a configuration.
  • OpenAPI: builder.Services.AddOpenApi() and app.MapOpenApi() are built in. Expose the document publicly only if the API is public.
  • Logging: log authentication failures, authorisation denials and rate-limit rejections, but never log tokens, passwords or full card numbers.
  • Dependencies: run dotnet list package --vulnerable in CI and keep packages patched.
  • Test security: write integration tests that call endpoints as the wrong user and assert a 404 or 403.

Summary

A secure ASP.NET Core API rests on a few layers: HTTPS and correct proxy configuration, tokens issued by a real identity provider and strictly validated, deny-by-default authorisation with ownership checks on every resource, validated input and minimal output, rate limiting, versioned contracts and errors that reveal nothing. None of it is difficult, but all of it has to be there.

Need a security-focused review of your .NET API, or a new API built this way from the start? See my .NET development services or contact me.

Share

We use cookies to personalize content and ads to make our site easier for you to use. We also share that information with third parties for advertising and analytics. By using this site, you agree to our terms & conditions. Privacy Policy