Waqas Ahmad — Software Architect & Technical Consultant - Available USA, Europe, Global

Waqas Ahmad — Software Architect & Technical Consultant

Specializing in

Distributed Systems

.NET ArchitectureCloud-Native ArchitectureAzure Cloud EngineeringAPI ArchitectureMicroservices ArchitectureEvent-Driven ArchitectureDatabase Design & Optimization

👋 Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.

Experienced across engineering ecosystems shaped by Microsoft, the Cloud Native Computing Foundation, and the Apache Software Foundation.

Available for remote consulting (USA, Europe, Global) — flexible across EST, PST, GMT & CET.

services
Article

Securing .NET APIs: Auth, Rate Limiting, and Headers

Securing .NET Web APIs: JWT, rate limiting, CORS, and security headers.

services
Read the article

Introduction

This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).

.NET Web APIs are a primary attack surface when auth, rate limiting, or headers are missing or misconfigured. This article is a full guide to securing .NET APIs: JWT validation, authorization policies, rate limiting, security headers (HSTS, CSP, X-Frame-Options, etc.), and CORS—with Program.cs-style setup. For architects and tech leads, layering auth, rate limiting, and headers reduces risk even when one control is wrong; we start with what API security and JWT/OAuth2/OIDC are, then build up to a complete setup.

If you are new to API security in .NET, start with Topics covered and Securing .NET APIs at a glance.

For a deeper overview of this topic, explore the full API Architecture guide.

Decision Context

  • System scale: Varies by context; the approach in this article applies to the scales and scenarios described in the body.
  • Team size: Typically small to medium teams; ownership and clarity matter more than headcount.
  • Time / budget pressure: Applicable under delivery pressure; I’ve used it in both greenfield and incremental refactors.
  • Technical constraints: .NET and related stack where relevant; constraints are noted in the article where they affect the approach.
  • Non-goals: This article does not optimize for every possible scenario; boundaries are stated where they matter.

What is API security and why it matters

API security is the set of practices and controls that protect your Web API from unauthorised access, data theft, abuse, and misuse. Unlike a traditional web app (HTML pages and cookies), an API is consumed by programs: mobile apps, single-page apps (SPAs), or other services. That means you must authenticate every request (e.g. with a JWT or API key), authorise what the caller is allowed to do (e.g. scopes or roles), and protect the API from abuse (e.g. rate limiting) and misconfiguration (e.g. security headers, CORS).

In .NET this typically includes: authentication (who is calling?), authorization (what are they allowed to do?), rate limiting (how much can they call?), security headers (HSTS, X-Content-Type-Options, etc.), and CORS (which origins can call from the browser). This article explains each and how to implement them in ASP.NET Core. Getting these right from the start reduces the chance of token forgery, scope escalation, DoS, and header-based attacks.


Securing .NET APIs at a glance

Concept What it is
Authentication Establish who is calling—validate JWT or API key; reject invalid or expired tokens.
Authorization Enforce what the caller is allowed to do—check scopes, roles, or policies on every protected endpoint.
JWT Compact, URL-safe token with header, payload (claims), and signature; sent as Authorization: Bearer <token>.
OAuth2 / OIDC Standards for authorisation (OAuth2) and identity (OpenID Connect); use an identity provider (Azure AD, IdentityServer, Auth0).
Rate limiting Cap requests per client (IP, key, or user) in a time window; return 429 with Retry-After when exceeded.
Security headers HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy—set on every response.
CORS Allow only trusted origins to call your API from the browser; never use wildcard for credentialed requests in production.
Loading diagram…

What is JWT?

JWT (JSON Web Token) is a compact, URL-safe format for representing claims between two parties. A JWT has three parts separated by dots: header (algorithm and type), payload (claims such as subject, audience, expiry, scopes), and signature (so the token cannot be forged). The API does not store session state; instead, the client sends the JWT in the Authorization: Bearer <token> header on each request. The API validates the signature (using the identity provider’s public keys), checks expiry and audience, and then uses the claims (e.g. scope, role) for authorization. JWTs are stateless from the API’s perspective and work well for SPAs and mobile apps that call your API.


What is OAuth2 and OpenID Connect?

OAuth2 is a framework for authorisation: it lets a user or app grant limited access to resources (e.g. “this app can read my profile”) without sharing passwords. OpenID Connect (OIDC) builds on OAuth2 and adds identity: it provides ID tokens (who the user is) and user info in a standard way. In practice, you use an identity provider (e.g. Azure AD, IdentityServer, Auth0) that supports OIDC. The client (SPA or mobile app) gets an access token (often a JWT) and sends it to your API; your API validates the token and checks scopes or roles to decide what the caller is allowed to do. Using a standard protocol (OAuth2/OIDC) gives you single sign-on, revocation, and auditability without building your own auth from scratch.


Authentication and authorization in ASP.NET Core

Use OAuth2 / OpenID Connect with a trusted identity provider (Azure AD, IdentityServer, or a managed service). In ASP.NET Core, add the JWT Bearer middleware and validate the token on every request. Configure Authority (issuer) and Audience so that only tokens issued for your API are accepted. Validate scopes or roles in attributes or requirement handlers so that only authorised callers can access sensitive operations.

Why we do this: Without validation, any client could send a forged or expired token. By validating issuer, audience, signature, and expiry, we ensure only tokens from our identity provider and intended for our API are accepted. Authorization policies then enforce what each caller is allowed to do.

Step 1: Add JWT Bearer authentication

// Program.cs – JWT Bearer
using Microsoft.AspNetCore.Authentication.JwtBearer;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://login.microsoftonline.com/your-tenant/v2.0";
        options.Audience = "api://your-api";
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidateAudience = true;
        options.TokenValidationParameters.ValidateLifetime = true;
    });

What this does: The middleware will fetch signing keys from the issuer’s metadata (e.g. .well-known/openid-configuration) and validate the token’s signature, issuer, audience, and expiry. Any request without a valid token to protected endpoints will receive 401 Unauthorized.

Step 2: Add authorization policies

// Program.cs – Authorization policies
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireReadScope", policy =>
        policy.RequireClaim("scope", "api.read"));
    options.AddPolicy("RequireWriteScope", policy =>
        policy.RequireClaim("scope", "api.write"));
});

What this does: Defines named policies that require specific claims (e.g. scope = api.write). Controllers or actions then use [Authorize(Policy = "RequireWriteScope")] so that only callers with that scope can access the endpoint.

Step 3: Use in the pipeline and on endpoints

// Program.cs – Pipeline order (after UseRouting)
app.UseAuthentication();
app.UseAuthorization();

// On controller or minimal API:
// [Authorize(Policy = "RequireWriteScope")]
// public IActionResult CreateOrder(...) { ... }

How this fits together: Every request passes through UseAuthentication(), which validates the JWT and sets HttpContext.User. UseAuthorization() then runs and checks the [Authorize] attribute and policy; if the caller does not have the required scope or role, the response is 403 Forbidden. Never trust client-supplied identity without validation. Store secrets (e.g. client credentials) in Azure Key Vault or your secrets manager; never in code or config in source control.


Rate limiting

Apply rate limiting per client (by IP, API key, or user ID) to prevent abuse and protect backend resources. In .NET 7+, use the built-in rate limiting middleware; on earlier versions, use a package like AspNetCoreRateLimit or a gateway (e.g. API Management) in front of your API. Define limits per endpoint or per policy (e.g. 100 requests per minute per IP for read, 10 for write). Return 429 Too Many Requests with a Retry-After header when the limit is exceeded so clients know when to retry.

Step 1: Add rate limiter and policy

// Program.cs – Rate limiting (.NET 7+)
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("api", opt =>
    {
        opt.Window = TimeSpan.FromMinutes(1);
        opt.PermitLimit = 100;
    });
    options.OnRejected = async (context, _) =>
    {
        context.HttpContext.Response.StatusCode = 429;
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
            context.HttpContext.Response.Headers.RetryAfter = retryAfter.TotalSeconds.ToString();
        await context.HttpContext.Response.WriteAsync("Too many requests.");
    };
});
app.UseRateLimiter();

What this does: Registers a fixed window limiter (100 requests per minute per connection). When the limit is exceeded, OnRejected sets 429 and adds Retry-After so clients can back off. Apply the limiter with [EnableRateLimiting("api")] on controller or endpoint, or globally via options.GlobalLimiter.

How this fits together: The middleware runs after routing; each request is counted against the limit (by default per IP). When the limit is hit, the request is short-circuited with 429 and never reaches your action. Use different limiters for read vs write (e.g. stricter for write) by defining multiple policies and applying them per endpoint.


Security headers

Set defensive headers on every response so that browsers and proxies enforce safer behaviour. Common headers include:

  • Strict-Transport-Security (HSTS) – force HTTPS.
  • X-Content-Type-Options: nosniff – prevent MIME sniffing.
  • X-Frame-Options: DENY (or SAMEORIGIN) – reduce clickjacking risk.
  • Content-Security-Policy (CSP) – restrict script and resource origins (tune for your app).
  • Referrer-Policy – control how much referrer info is sent.

You can add these in middleware or in a reverse proxy (e.g. API Management, nginx). Keep CSP tuned so that legitimate scripts and styles still load.

Step 1: Inline middleware (minimal)

// Program.cs – Security headers (inline)
app.Use(async (context, next) =>
{
    context.Response.Headers["X-Content-Type-Options"] = "nosniff";
    context.Response.Headers["X-Frame-Options"] = "DENY";
    context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
    await next();
});

What this does: Adds three headers to every response. Place this early in the pipeline (e.g. after UseRouting) so that even error responses get the headers.

Step 2: Dedicated middleware (HSTS in production)

// Middleware/SecurityHeadersMiddleware.cs
public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;
    private readonly bool _addHsts;

    public SecurityHeadersMiddleware(RequestDelegate next, IWebHostEnvironment env)
    {
        _next = next;
        _addHsts = env.IsProduction();
    }

    public async Task InvokeAsync(HttpContext context)
    {
        context.Response.Headers["X-Content-Type-Options"] = "nosniff";
        context.Response.Headers["X-Frame-Options"] = "DENY";
        context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
        if (_addHsts)
            context.Response.Headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
        await _next(context);
    }
}
// Program.cs – register: app.UseMiddleware<SecurityHeadersMiddleware>();

What this file is: A dedicated middleware that sets the same headers and adds HSTS only in production (so local HTTP still works in dev). Encapsulating headers in a class keeps Program.cs clean and makes it easy to add CSP or other headers later.

How this fits together: The middleware runs for every request and adds headers before calling _next. The response then goes through the rest of the pipeline; when it is sent, the headers are included. Tune Content-Security-Policy when your API serves HTML or when you need to restrict script sources; start with report-only mode if unsure, then enforce once validated.


CORS

Configure CORS explicitly so that only allowed origins can call your API from the browser. Do not use wildcard in production for credentialed requests; list the front-end origins you trust. Use AllowCredentials() only when you need cookies or auth headers and your origins are specific.

Cross-Origin Resource Sharing (CORS) is a browser mechanism: when your SPA (e.g. on https://app.example.com) calls your API (e.g. on https://api.example.com), the browser sends an Origin header and may send a preflight (OPTIONS) request. Your API must respond with Access-Control-Allow-Origin and related headers so the browser allows the response. If you do not configure CORS, the browser blocks the response and the front end cannot read it.

// Program.cs – CORS policy
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
    ?? new[] { "https://your-frontend.com" };
builder.Services.AddCors(options =>
{
    options.AddPolicy("ApiCors", policy =>
    {
        policy.WithOrigins(allowedOrigins)
            .AllowAnyMethod()
            .AllowAnyHeader();
        // Use .AllowCredentials() only when needed (cookies/auth headers) and origins are explicit
    });
});
// After UseRouting, before UseAuthorization:
app.UseCors("ApiCors");

What this does: Defines a named policy that allows only the configured origins; methods and headers are open so that your front end can send GET, POST, and Authorization. In production, never use * for credentialed requests; list each allowed origin (e.g. from config) so that only your known front ends can call the API.


Best practices and common issues

Do: Validate issuer and audience for JWT; enforce authorization (scope/role) on every protected endpoint; apply rate limiting (including on public endpoints); set security headers on every response; configure CORS with explicit origins; store secrets in Key Vault (or equivalent) and use Managed Identity where possible; return 429 with Retry-After when rate limited.

Don’t: Skip token validation or use wildcard CORS for credentialed requests; store secrets in appsettings or source control; forget to validate scopes/roles (accepting a valid token is not enough); leave public or anonymous endpoints without rate limiting.

Common issues:

  • Token validation misconfiguration: Wrong Authority, Audience, or issuer validation allows invalid or cross-tenant tokens. Always validate issuer and audience; use metadata from the identity provider so that key rotation is handled. Test with invalid and expired tokens.
  • Rate limiting too strict or too loose: Too strict and legitimate users get 429; too loose and abuse is possible. Measure traffic and set limits per endpoint (e.g. read vs write); use sliding window or token bucket and return Retry-After on 429.
  • Certificate and TLS issues: Expired or misconfigured TLS certs cause silent failures or browser errors. Use managed certificates where possible; define rotation and expiry monitoring. Enforce TLS 1.2 minimum.
  • CORS blocking legitimate frontends: Forgetting to add a new front-end origin or using wildcard with credentials breaks production. List allowed origins explicitly; use environment-specific config.
  • Security headers missing or wrong: Missing HSTS, X-Content-Type-Options, or CSP leaves the app exposed. Add security headers in middleware or at the reverse proxy; tune CSP so that legitimate scripts still load.
  • Secrets in config: Connection strings or API keys in appsettings or plain environment variables. Use Azure Key Vault and Managed Identity so that no secrets are stored in code or config in source control.
  • Skipping scope/role validation: Accepting a valid token without checking scopes or roles lets callers access more than intended. Always enforce authorization (e.g. [Authorize(Policy = "RequireWriteScope")]) on sensitive endpoints.
  • No rate limiting on public endpoints: Public or anonymous endpoints (e.g. login, sign-up) are easy targets. Apply rate limiting (per IP or per key) even when auth is not required; use captcha or throttling for high-risk actions.

Summary

API security in .NET means authentication (validate JWT), authorization (enforce scopes/roles), rate limiting per client, security headers, and CORS—combine them so one misconfiguration does not leave the API exposed. Skipping rate limiting or headers leads to abuse and information leakage; using AddJwtBearer with Authority/Audience, policies, and middleware keeps endpoints predictable. Next, add JWT validation and one authorization policy to your API, then add rate limiting and security-headers middleware (and set CORS to explicit origins only).

  • API security in .NET means authentication (validate JWT), authorization (enforce scopes/roles), rate limiting (per client), security headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy), and CORS (explicit origins).
  • Use AddJwtBearer with Authority and Audience; add authorization policies and [Authorize(Policy = "...")] on every protected endpoint.
  • Use .NET 7+ rate limiting (or a package/gateway) and return 429 with Retry-After when the limit is exceeded.
  • Set security headers in middleware or at the reverse proxy; use a dedicated middleware class for clarity and HSTS in production only.
  • Configure CORS with explicit origins; never use wildcard for credentialed requests in production.
  • Layer these controls so that authentication, limits, and headers together reduce risk. Use the FAQs below as a quick reference when hardening .NET APIs.

Position & Rationale

I treat auth (JWT validation with Authority and Audience) and authorization (policies and [Authorize] on every protected endpoint) as non-negotiable for any API that handles sensitive data. I add rate limiting on all endpoints—including public ones like login and sign-up—so abuse and brute force are bounded; I return 429 with Retry-After. I set security headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy) in middleware or at the reverse proxy; I never rely on a single control. I configure CORS with explicit origins and never use wildcard for credentialed requests in production. I reject storing secrets in appsettings or env in source control; I use Key Vault and Managed Identity. I avoid accepting a valid token without enforcing scopes or roles—that’s the main way APIs over-privilege callers.


Trade-Offs & Failure Modes

  • What this sacrifices: Some simplicity, extra structure, or operational cost depending on the topic; the article body covers specifics.
  • Where it degrades: Under scale or when misapplied; early warning signs include drift from the intended use and repeated workarounds.
  • How it fails when misapplied: Using it where constraints don’t match, or over-applying it. The “When I Would Use This Again” section below reinforces boundaries.
  • Early warning signs: Team confusion, bypasses, or “we’re doing X but not really” indicate a mismatch.

What Most Guides Miss

Most guides show how to add JWT validation and then stop. What they skip: who validates what where—API keys vs user tokens vs service-to-service; failure mode when the auth server is down (cache tokens? fail closed?); and audit—logging who called what so you can debug and comply. The hard part isn’t the middleware, it’s the boundaries and the “what happens when” that nobody documents. In real systems you also need a clear story for key rotation, scope creep, and who owns secrets; most posts don’t go there.


Decision Framework

  • If the context matches the assumptions in this article → Apply the approach as described; adapt to your scale and team.
  • If constraints differ → Revisit Decision Context and Trade-Offs; simplify or choose an alternative.
  • If you’re under heavy time pressure → Use the minimal subset that gives the most value; expand later.
  • If ownership is unclear → Clarify before scaling the approach; unclear ownership is an early warning sign.

You can also explore more patterns in the API Architecture resource page.

Key Takeaways

  • The article body and Summary capture the technical content; this section distils judgment.
  • Apply the approach where context and constraints match; avoid over-application.
  • Trade-offs and failure modes are real; treat them as part of the decision.
  • Revisit “When I Would Use This Again” when deciding on a new project or refactor.

If you’re building or modernizing API platforms, I provide API architecture consulting covering API-first design, governance, and integration strategy.

When I Would Use This Again — and When I Wouldn’t

I would use this securing approach again for any .NET API that handles user data or sensitive operations—auth, authorization, rate limiting, headers, and CORS from day one. I’d insist on scope/role checks, not just “valid token.” I wouldn’t ship an API without rate limiting on login and sign-up. I wouldn’t use CORS wildcard with credentials in production. For an internal-only API behind a VPN with no public surface, the minimum might be auth and headers; as soon as the API is reachable from the internet or multiple clients, the full set is mandatory in my view. If the team can’t own an identity provider or Key Vault, I’d still add the patterns and treat getting those as a prerequisite for production.


services
Frequently Asked Questions

Frequently Asked Questions

How do I validate JWT tokens correctly in .NET?

Use AddJwtBearer with Authority (issuer URL) and Audience; ensure ValidateIssuer and ValidateAudience are true. The middleware will fetch signing keys from the issuer’s metadata and validate signature and expiry. Never skip validation for convenience.

What rate limit should I set for my API?

Start with conservative limits (e.g. 100 requests per minute per IP for read, 10 for write) and measure; adjust based on real traffic and abuse patterns. Use sliding window or token bucket; return 429 with Retry-After when the limit is exceeded.

How do I handle certificate rotation for my API?

Use Azure App Service managed certificates or Let’s Encrypt with auto-renewal where possible. For custom certs, store in Key Vault and use a rotation policy; set alerts for expiry (e.g. 30 days before). Document the rotation process in a runbook.

What security headers are essential for a .NET API?

At minimum: Strict-Transport-Security (HSTS), X-Content-Type-Options: nosniff, X-Frame-Options: DENY (or SAMEORIGIN), Referrer-Policy. Add Content-Security-Policy when the API serves HTML or when you need to restrict script sources; tune for your app.

Should I use API keys or JWT for API authentication?

Use JWT (OAuth2/OpenID Connect) when you have users or apps that need scoped access and revocation. Use API keys only for machine-to-machine simple cases; even then, prefer Managed Identity when on Azure. Never use API keys for user-facing apps.

How do I prevent secrets from leaking in my .NET API?

Use Azure Key Vault (or equivalent) for connection strings, API keys, and certificates; reference them via configuration (e.g. Key Vault config provider). Use Managed Identity for service-to-service auth. Never commit secrets to source control; use secret scanning in CI.

What is the difference between authentication and authorization?

Authentication answers “who is calling?”—you validate the token or credentials and establish identity. Authorization answers “what are they allowed to do?”—you check scopes, roles, or policies and allow or deny the operation. Always do both on every protected endpoint.

How do I return 429 with Retry-After when rate limited?

When using .NET 7+ rate limiting, use an OnRejected callback to set context.HttpContext.Response.StatusCode = 429 and add the Retry-After header from the RateLimitLease (e.g. TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)). This helps clients back off and retry correctly.

Should I validate JWT in a gateway or in the API?

You can do either: API Management or a gateway can validate the token and forward claims to the API (fewer validations, single point of config), or the API can validate the token itself (simpler topology, no gateway dependency). Ensure the token is validated once and never trust unvalidated claims.

What is API security?

API security is the set of practices that protect your Web API from unauthorised access, data theft, and abuse: authentication (who is calling?), authorization (what are they allowed to do?), rate limiting, security headers, and CORS.

Why use JWT instead of session cookies for APIs?

APIs are often called by mobile apps or SPAs that do not use browser cookies. JWT (or bearer tokens) are sent in the Authorization header and work across clients. They also support scopes and expiry and can be validated without server-side session storage.

How do I configure CORS for my .NET API?

Use AddCors() with a named policy that allows specific origins (e.g. https://your-frontend.com). Use AllowCredentials() only when you need cookies or auth headers and your origins are explicit. Do not use wildcard in production for credentialed requests.

What is rate limiting and why do I need it?

Rate limiting caps how many requests a client can send in a time window. It prevents abuse, DoS, and cost blowouts. Return 429 Too Many Requests with Retry-After when the limit is exceeded. Use .NET 7+ built-in middleware or a package like AspNetCoreRateLimit.

How do I enforce authorization per endpoint?

Use [Authorize] on controllers or actions; use [Authorize(Policy = "PolicyName")] for scope or role-based checks. Configure policies in AddAuthorization() (e.g. RequireClaim("scope", "api.write")). Never rely only on hiding URLs; always check on the server.

What is HSTS and why add it?

Strict-Transport-Security (HSTS) tells browsers to use HTTPS only when talking to your API. It reduces the risk of downgrade attacks and accidental HTTP. Set max-age (e.g. 31536000 for one year) and includeSubDomains if applicable.

Should I use API keys for machine-to-machine auth?

API keys are simple but hard to rotate and revoke per client. Prefer Managed Identity when on Azure; use client credentials (OAuth2) or certificate-based auth when you need per-client identity. Store keys in Key Vault and rotate them periodically.

services
Related Guides & Resources

services
Related services