🔐 Hands-On Tutorial

ASP.NET Core 8 API Security: JWT Authentication, CSRF Protection & Rate Limiting

Your API is running in production. You added JWT auth because every tutorial said to. Your CORS policy is AllowAnyOrigin() because it was the easiest way to stop the browser errors. Your tokens live in localStorage. You have no rate limiting. You are one motivated attacker away from a bad day.

Security isn't a checkbox. It's a set of interlocking decisions: which auth scheme to use and why, how to protect cookie flows from CSRF, how to stop brute-force and credential stuffing, how to signal browsers to enforce your policies, and critically — how to recognize the mistakes you've already made. This tutorial shows you all of it, with a runnable Notes API that demonstrates every pattern.

What You'll Build

A production-hardened Notes API (folder: tutorials/aspnet-core/NotesApiSecurity/) demonstrating every security layer:

  • JWT bearer authentication for API clients and mobile apps, with proper signing key management
  • Secure cookie authentication for browser flows, with HttpOnly, Secure, and SameSite flags explained
  • Role-based and policy-based authorization plus resource ownership checks
  • Per-IP and per-user rate limiting returning RFC-7807 ProblemDetails on 429
  • CSRF protection wired only to cookie-authenticated endpoints
  • Secure CORS with explicit origins and preflight handling
  • Secure headers: HSTS, CSP basics, X-Content-Type-Options, and HTTPS redirection
  • Secret handling with IConfiguration, environment variables, and what never to hard-code
  • Mini attack lab: eight real mistakes, why each is dangerous, and the exact fix

Project Setup

Start from a minimal API template. The Notes API is intentionally simple: users register, log in, and manage their own notes. The simplicity keeps the focus on security layers, not business logic.

Create the Project & Add Packages

Terminal
dotnet new webapi -n NotesApiSecurity -minimal
cd NotesApiSecurity

# Auth
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Authentication.Cookies

# Rate limiting (built-in .NET 7+, no package needed)
# CSRF (built-in ASP.NET Core, no package needed)

# Useful extras
dotnet add package Microsoft.AspNetCore.DataProtection

Models & In-Memory Store

Two simple records power the whole tutorial. Keep them minimal so security code stays front and center.

Models.cs
// User record - password hashing shown in JWT section
record AppUser(string Id, string Email, string PasswordHash, string Role);

record Note
{
    public string   Id        { get; init; } = Guid.NewGuid().ToString();
    public string   OwnerId   { get; init; } = "";
    public string   Title     { get; init; } = "";
    public string   Body      { get; init; } = "";
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}

record LoginRequest(string Email, string Password);
record CreateNoteRequest(string Title, string Body);

// In-memory stores (swap for EF Core in production)
static class Store
{
    public static readonly List<AppUser> Users = new();
    public static readonly List<Note>    Notes = new();
}
In-Memory vs Real Database

This tutorial uses in-memory collections to keep setup zero-friction. In production, store users in a database with ASP.NET Core Identity or a custom user table. Never roll your own password hashing — use PasswordHasher<T> or BCrypt.Net.

JWT Bearer Authentication

JWT (JSON Web Token) is a signed, self-contained token. The server issues it on login; the client sends it in the Authorization: Bearer <token> header on every subsequent request. No server-side session. No database lookup per request. Just signature verification.

Configure JWT in Program.cs

Register the bearer scheme. Pull the signing key from configuration — never hard-code it. Set tight validation parameters.

Program.cs — JWT Setup
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

var jwtKey = builder.Configuration["Jwt:Key"]
    ?? throw new InvalidOperationException("Jwt:Key is required");

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(jwtKey)),
            ClockSkew                = TimeSpan.FromSeconds(30) // tight clock skew
        };

        // Return 401 JSON instead of HTML redirect
        options.Events = new JwtBearerEvents
        {
            OnChallenge = async ctx =>
            {
                ctx.HandleResponse();
                ctx.Response.StatusCode  = 401;
                ctx.Response.ContentType = "application/problem+json";
                await ctx.Response.WriteAsJsonAsync(new
                {
                    type   = "https://tools.ietf.org/html/rfc7235#section-3.1",
                    title  = "Unauthorized",
                    status = 401,
                    detail = "A valid bearer token is required."
                });
            }
        };
    });

builder.Services.AddAuthorization();

Issue Tokens on Login

Validate credentials, then build and sign the JWT. Include claims that authorization policies will rely on: sub, email, role.

Login Endpoint — JWT Issuance
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

app.MapPost("/auth/login/jwt", (LoginRequest req, IConfiguration config) =>
{
    var user = Store.Users.FirstOrDefault(u => u.Email == req.Email);
    if (user is null || !VerifyPassword(req.Password, user.PasswordHash))
        return Results.Problem(
            title:      "Invalid credentials",
            detail:     "Email or password is incorrect.",
            statusCode: 401);

    var key    = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]!));
    var creds  = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

    var claims = new[]
    {
        new Claim(JwtRegisteredClaimNames.Sub,   user.Id),
        new Claim(JwtRegisteredClaimNames.Email, user.Email),
        new Claim(ClaimTypes.Role,               user.Role),
        new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString())
    };

    var token = new JwtSecurityToken(
        issuer:             config["Jwt:Issuer"],
        audience:           config["Jwt:Audience"],
        claims:             claims,
        expires:            DateTime.UtcNow.AddHours(1), // short-lived
        signingCredentials: creds);

    return Results.Ok(new
    {
        AccessToken = new JwtSecurityTokenHandler().WriteToken(token),
        ExpiresAt   = token.ValidTo
    });
})
.WithName("LoginJwt")
.WithTags("Auth")
.AllowAnonymous();
Keep Tokens Short-Lived

JWT tokens cannot be revoked server-side (unless you maintain a blocklist). Set expiry to 15–60 minutes and implement refresh token rotation for longer sessions. A stolen long-lived JWT is as good as a stolen password until it expires.

Protect Endpoints with JWT

Protected Notes Endpoints
app.MapGet("/notes", (ClaimsPrincipal user) =>
{
    var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    var notes  = Store.Notes.Where(n => n.OwnerId == userId);
    return TypedResults.Ok(notes);
})
.RequireAuthorization()   // enforce JWT or cookie
.WithTags("Notes");

app.MapPost("/notes", (CreateNoteRequest req, ClaimsPrincipal user) =>
{
    var note = new Note
    {
        OwnerId = user.FindFirstValue(ClaimTypes.NameIdentifier)!,
        Title   = req.Title,
        Body    = req.Body
    };
    Store.Notes.Add(note);
    return TypedResults.Created($"/notes/{note.Id}", note);
})
.RequireAuthorization()
.WithTags("Notes");

JWT vs Cookies: When to Use Each

This is the question every team debates. The honest answer: both have real tradeoffs, and many production systems use both simultaneously for different clients.

Use JWT When…
  • Client is a mobile app, SPA with token management, or another service
  • You need stateless, horizontally scalable auth across microservices
  • Clients must pass tokens across different domains (API gateway patterns)
  • Short token lifetimes (15 min) + refresh rotation are acceptable
Use Cookies When…
  • Client is a traditional browser application (server-rendered or MPA)
  • You want the browser to manage storage — eliminates XSS token theft risk
  • Session invalidation (logout) must be immediate and reliable
  • Blazor Server or Razor Pages applications
Supporting Both Schemes on One Endpoint
// Accept JWT bearer OR cookie — first match wins
builder.Services.AddAuthorization(options =>
{
    options.DefaultPolicy = new AuthorizationPolicyBuilder()
        .AddAuthenticationSchemes(
            JwtBearerDefaults.AuthenticationScheme,
            CookieAuthenticationDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser()
        .Build();
});

// Endpoint automatically accepts either scheme
app.MapGet("/notes", (ClaimsPrincipal user) =>
{
    var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    return TypedResults.Ok(Store.Notes.Where(n => n.OwnerId == userId));
})
.RequireAuthorization(); // uses the combined DefaultPolicy
Never Mix AllowAnyOrigin() with Cookie Auth

If your CORS policy allows any origin and you use cookies for auth, you've effectively defeated CSRF protection. Always pair cookie auth with explicit allowed origins. The browser will enforce your SameSite policy, but only if CORS doesn't override it first.

Authorization: Roles, Policies & Resource Checks

Authentication answers "who are you?" Authorization answers "what can you do?" ASP.NET Core gives you three levels: role-based, policy-based, and resource-based. Use all three together.

Role-Based Authorization

Roles are claims. Assign them at login. Check them at the endpoint.

Role-Based Checks
// Admin-only endpoint
app.MapGet("/admin/users", () =>
    TypedResults.Ok(Store.Users.Select(u => new { u.Id, u.Email, u.Role })))
.RequireAuthorization(policy => policy.RequireRole("Admin"))
.WithTags("Admin");

// Multiple roles allowed
app.MapGet("/notes/all", () => TypedResults.Ok(Store.Notes))
.RequireAuthorization(policy => policy.RequireRole("Admin", "Moderator"))
.WithTags("Admin");

Policy-Based Authorization

Policies express complex requirements cleanly. Define them once, apply them everywhere.

Named Authorization Policies
builder.Services.AddAuthorization(options =>
{
    // Require a verified email claim
    options.AddPolicy("VerifiedUser", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("email_verified", "true"));

    // Premium feature gate
    options.AddPolicy("PremiumUser", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("subscription", "premium", "enterprise"));

    // Admins bypass subscription check
    options.AddPolicy("PremiumOrAdmin", policy =>
        policy.RequireAuthenticatedUser()
              .RequireAssertion(ctx =>
                  ctx.User.IsInRole("Admin") ||
                  ctx.User.HasClaim("subscription", "premium")));
});

// Apply at endpoint
app.MapPost("/notes/export", (ClaimsPrincipal user) =>
{
    // Export logic
    return TypedResults.Ok();
})
.RequireAuthorization("PremiumOrAdmin")
.WithTags("Notes");

Resource-Based Authorization

A note belongs to a user. Only the owner (or an admin) should be able to edit or delete it. Role/policy checks don't know about the specific resource — you need to check ownership in the handler.

Resource Ownership Check
app.MapDelete("/notes/{id}", (string id, ClaimsPrincipal user) =>
{
    var note = Store.Notes.FirstOrDefault(n => n.Id == id);
    if (note is null)
        return Results.Problem(title: "Not found", statusCode: 404);

    var userId  = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    var isAdmin = user.IsInRole("Admin");

    // Resource-based check: owner OR admin
    if (note.OwnerId != userId && !isAdmin)
        return Results.Problem(
            title:      "Forbidden",
            detail:     "You do not own this note.",
            statusCode: 403);

    Store.Notes.Remove(note);
    return Results.NoContent();
})
.RequireAuthorization()
.WithTags("Notes");
Return 404, Not 403, for Hidden Resources

Returning 403 on a resource the user doesn't own confirms the resource exists — useful intelligence for an attacker enumerating IDs. Return 404 instead to hide existence entirely. Only return 403 when the user legitimately knows the resource exists but lacks permission to act on it.

Rate Limiting with ProblemDetails

Without rate limiting, your login endpoint is a brute-force target. Your notes endpoints are a scraping target. Your server is a DoS target. ASP.NET Core's built-in rate limiting middleware handles all of this. The key is partitioning: anonymous traffic by IP, authenticated traffic by user ID.

Configure Rate Limiter Policies

Program.cs — Rate Limiter Setup
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    // Strict policy for auth endpoints — brute-force protection
    options.AddFixedWindowLimiter("auth-strict", opt =>
    {
        opt.PermitLimit        = 5;
        opt.Window             = TimeSpan.FromMinutes(1);
        opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        opt.QueueLimit         = 0; // reject immediately, no queue
    });

    // Per-user sliding window for authenticated API calls
    options.AddPolicy("per-user", ctx =>
        RateLimitPartition.GetSlidingWindowLimiter(
            partitionKey: ctx.User.FindFirstValue(ClaimTypes.NameIdentifier)
                          ?? ctx.Connection.RemoteIpAddress?.ToString()
                          ?? "unknown",
            factory: _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit         = 100,
                Window              = TimeSpan.FromMinutes(1),
                SegmentsPerWindow   = 6   // 10-second buckets
            }));

    // Per-IP fallback for anonymous endpoints
    options.AddPolicy("per-ip", ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 30,
                Window      = TimeSpan.FromMinutes(1)
            }));

    // Return RFC-7807 ProblemDetails on 429
    options.OnRejected = async (ctx, ct) =>
    {
        ctx.HttpContext.Response.StatusCode  = 429;
        ctx.HttpContext.Response.ContentType = "application/problem+json";

        var retryAfter = ctx.Lease.TryGetMetadata(
            MetadataName.RetryAfter, out var retry) ? (int?)retry.TotalSeconds : null;

        if (retryAfter.HasValue)
            ctx.HttpContext.Response.Headers.RetryAfter = retryAfter.Value.ToString();

        await ctx.HttpContext.Response.WriteAsJsonAsync(new
        {
            type     = "https://tools.ietf.org/html/rfc6585#section-4",
            title    = "Too Many Requests",
            status   = 429,
            detail   = "Rate limit exceeded. Please slow down.",
            retryAfterSeconds = retryAfter
        }, ct);
    };
});

Apply Policies to Endpoints

Applying Rate Limit Policies
// Middleware must be registered in the right order
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();  // after auth so user identity is available for partitioning

// Strict limit on login — 5 attempts/minute regardless of IP
app.MapPost("/auth/login/jwt",    /* handler */)
   .RequireRateLimiting("auth-strict");

app.MapPost("/auth/login/cookie", /* handler */)
   .RequireRateLimiting("auth-strict");

// Authenticated notes endpoints — per-user sliding window
app.MapGet("/notes",         /* handler */).RequireAuthorization().RequireRateLimiting("per-user");
app.MapPost("/notes",        /* handler */).RequireAuthorization().RequireRateLimiting("per-user");
app.MapDelete("/notes/{id}", /* handler */).RequireAuthorization().RequireRateLimiting("per-user");

// Public endpoints — per-IP
app.MapGet("/health", () => Results.Ok()).RequireRateLimiting("per-ip");
Always Include Retry-After

A 429 without a Retry-After header tells the client nothing useful. They'll immediately retry, making the problem worse. The header tells clients exactly when to back off. Well-behaved API clients (and most HTTP libraries) will respect it automatically.

CSRF Protection for Cookie Flows

Cross-Site Request Forgery exploits the fact that browsers automatically send cookies with every request — including ones triggered by malicious third-party pages. An attacker can embed a form on their site that POSTs to your API; the victim's browser helpfully includes their auth cookie.

JWT bearer auth is immune: browsers don't auto-send Authorization headers. Cookie auth is vulnerable. Protect cookie-authenticated mutation endpoints with CSRF tokens.

Register Antiforgery Services

Program.cs — Antiforgery Setup
builder.Services.AddAntiforgery(options =>
{
    options.HeaderName       = "X-CSRF-TOKEN";  // SPA sends token in this header
    options.Cookie.Name      = "__Host-csrf";
    options.Cookie.HttpOnly  = false;           // JS must read this cookie to send the header
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite  = SameSiteMode.Strict;
});

var app = builder.Build();
app.UseAntiforgery();

Provide the Token & Validate It

CSRF Token Endpoint + Protected Mutation
// Client fetches this token before any state-changing request
app.MapGet("/auth/csrf-token", (IAntiforgery antiforgery, HttpContext ctx) =>
{
    var tokens = antiforgery.GetAndStoreTokens(ctx);
    return Results.Ok(new { token = tokens.RequestToken });
})
.RequireAuthorization()
.WithTags("Auth");

// Validate CSRF token on cookie-authenticated mutations
app.MapPost("/notes", async (
    CreateNoteRequest req,
    ClaimsPrincipal   user,
    IAntiforgery      antiforgery,
    HttpContext       ctx) =>
{
    // Only validate for cookie auth — JWT requests don't need CSRF
    var isCookieAuth = ctx.User.Identity?.AuthenticationType ==
                       CookieAuthenticationDefaults.AuthenticationScheme;

    if (isCookieAuth)
        await antiforgery.ValidateRequestAsync(ctx); // throws AntiforgeryValidationException on failure

    var note = new Note
    {
        OwnerId = user.FindFirstValue(ClaimTypes.NameIdentifier)!,
        Title   = req.Title,
        Body    = req.Body
    };
    Store.Notes.Add(note);
    return TypedResults.Created($"/notes/{note.Id}", note);
})
.RequireAuthorization()
.WithTags("Notes");
SameSite=Strict vs CSRF Tokens

SameSite=Strict already blocks most CSRF vectors in modern browsers. Use it as your first defence. Add explicit CSRF tokens as defence-in-depth for older browsers and edge cases. Both together is the production standard. SameSite=Lax allows top-level GET navigations but blocks cross-site POST — a reasonable middle ground if strict breaks legitimate flows.

Secure CORS Patterns

CORS is a browser security mechanism. It doesn't protect your API from server-to-server calls, Postman, or curl. What it does do is prevent malicious web pages from making authenticated requests to your API using the visitor's browser. Get the configuration wrong and you've handed that protection back to the attacker.

Define CORS Policies

Program.cs — CORS Setup
var allowedOrigins = builder.Configuration
    .GetSection("Cors:AllowedOrigins")
    .Get<string[]>() ?? Array.Empty<string>();

builder.Services.AddCors(options =>
{
    // Strict policy for authenticated endpoints
    options.AddPolicy("AuthenticatedClients", policy =>
        policy.WithOrigins(allowedOrigins)         // explicit list — never AllowAnyOrigin
              .AllowAnyHeader()
              .WithMethods("GET", "POST", "PUT", "DELETE")
              .AllowCredentials()                  // required for cookie auth
              .SetPreflightMaxAge(TimeSpan.FromHours(1)));

    // Permissive policy for public read-only endpoints
    options.AddPolicy("PublicRead", policy =>
        policy.AllowAnyOrigin()
              .WithMethods("GET")
              .WithHeaders("Content-Type", "Accept"));
});

var app = builder.Build();

// Apply globally — endpoints override per-endpoint as needed
app.UseCors("AuthenticatedClients");

Per-Endpoint CORS Override

Per-Endpoint CORS Policy
// Public health check — allow any origin
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }))
   .RequireCors("PublicRead");

// Authenticated endpoint — inherits global AuthenticatedClients policy
app.MapGet("/notes", (ClaimsPrincipal user) => {/* handler */})
   .RequireAuthorization();
appsettings.json — Allowed Origins
{
  "Cors": {
    "AllowedOrigins": [
      "https://app.yourcompany.com",
      "https://staging.yourcompany.com"
    ]
  }
}
AllowAnyOrigin + AllowCredentials = Runtime Exception (and a Security Hole)

ASP.NET Core will throw at startup if you combine AllowAnyOrigin() with AllowCredentials(). The tempting workaround is SetIsOriginAllowed(_ => true) — this bypasses the runtime check but creates the exact security hole CORS was designed to prevent. Any website can now make authenticated requests to your API using a visitor's cookies. Never do it.

Secure Headers & HTTPS

HTTP response headers let you instruct the browser to enforce additional security policies. These are one-time setup items in middleware but they're meaningful defence-in-depth, especially against content injection and protocol downgrade attacks.

HTTPS Redirection & HSTS

HTTPS & HSTS Middleware
var app = builder.Build();

// Redirect HTTP → HTTPS
app.UseHttpsRedirection();

// Tell browsers to only connect via HTTPS for the next year
if (!app.Environment.IsDevelopment())
{
    app.UseHsts(); // adds Strict-Transport-Security header
}

// Customise HSTS if needed
builder.Services.AddHsts(options =>
{
    options.Preload           = true;            // opt into HSTS preload list
    options.IncludeSubDomains = true;
    options.MaxAge            = TimeSpan.FromDays(365);
});

Security Headers Middleware

ASP.NET Core doesn't add all security headers by default. Add them via custom middleware.

Security Headers Middleware
app.Use(async (ctx, next) =>
{
    var headers = ctx.Response.Headers;

    // Prevent browsers from MIME-sniffing — stops content-type confusion attacks
    headers["X-Content-Type-Options"] = "nosniff";

    // Block clickjacking by preventing your page loading in iframes
    headers["X-Frame-Options"] = "DENY";

    // Basic Content Security Policy — tighten per your actual requirements
    headers["Content-Security-Policy"] =
        "default-src 'self'; " +
        "script-src 'self'; " +
        "style-src 'self'; " +
        "img-src 'self' data:; " +
        "frame-ancestors 'none';";

    // Don't send referrer on cross-origin requests (avoids leaking internal paths)
    headers["Referrer-Policy"] = "strict-origin-when-cross-origin";

    // Disable browser features you don't use
    headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()";

    await next();
});
Test Your Headers

Use securityheaders.com to scan your deployed API and get a graded report. It's free and takes seconds. Aim for A or A+. The notes API with the above middleware scores A.

Secret & Configuration Handling

Hard-coded secrets in source code are one of the most common causes of production breaches. Git history is forever. If a secret is ever committed, rotate it — don't just delete it from the latest commit.

Development: User Secrets

Terminal — Init User Secrets
dotnet user-secrets init
dotnet user-secrets set "Jwt:Key" "your-super-secret-key-min-32-chars-long!!"
dotnet user-secrets set "Jwt:Issuer"   "https://notesapi.local"
dotnet user-secrets set "Jwt:Audience" "https://notesapi.local"

Production: Environment Variables

Environment Variable Configuration
# Set at deploy time — never in Dockerfile or committed config
export Jwt__Key="production-secret-min-32-chars!!"
export Jwt__Issuer="https://api.yourcompany.com"
export Jwt__Audience="https://api.yourcompany.com"

# .NET maps __ to : automatically
# Jwt__Key in env == Jwt:Key in IConfiguration

Read Secrets Safely in Code

Fail Fast on Missing Secrets
// Fail at startup with a clear error — better than failing at runtime
var jwtKey = builder.Configuration["Jwt:Key"]
    ?? throw new InvalidOperationException(
        "Jwt:Key is not configured. Set it via user-secrets (dev) or environment variable (prod).");

// Validate key length — HS256 minimum is 32 bytes
if (Encoding.UTF8.GetByteCount(jwtKey) < 32)
    throw new InvalidOperationException(
        "Jwt:Key must be at least 32 characters. Current key is too short.");

// Typed options for structured config
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));

record JwtOptions
{
    public string Key      { get; init; } = "";
    public string Issuer   { get; init; } = "";
    public string Audience { get; init; } = "";
}
Minimum Key Length Matters

HS256 signing requires a key of at least 256 bits (32 bytes). Short keys are guessable. A 12-character key like "mysecretkey123" is crackable in seconds with jwt_tool or hashcat. Generate your production key with openssl rand -base64 48 and store it in a secrets manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault).

Mini Attack Lab: 8 Common Mistakes

These are real mistakes that appear in production code regularly. Each one has a specific attack vector and a specific fix. Review your own API against this list.

Mistake 1: JWT in localStorage

❌ Vulnerable — localStorage Storage
// Any injected script can steal this
const token = await loginAndGetJwt();
localStorage.setItem("jwt", token);

// Later, on every request:
fetch("/notes", {
  headers: { Authorization: `Bearer ${localStorage.getItem("jwt")}` }
});
✅ Fixed — In-Memory Storage
// Stored in JS closure — gone on tab close, invisible to other scripts
let accessToken = null;

async function login(email, password) {
    const res = await fetch("/auth/login/jwt", { method: "POST", body: JSON.stringify({email, password}) });
    const data = await res.json();
    accessToken = data.accessToken;  // in memory only
}

fetch("/notes", {
  headers: { Authorization: `Bearer ${accessToken}` }
});

Mistake 2: Over-Broad CORS

❌ Vulnerable vs ✅ Fixed
// ❌ Allows any website to make credentialed requests to your API
policy.SetIsOriginAllowed(_ => true).AllowCredentials();

// ✅ Enumerate exactly which origins you serve
policy.WithOrigins("https://app.yourcompany.com").AllowCredentials();

Mistake 3: Symmetric Key Too Short

❌ Vulnerable vs ✅ Fixed
// ❌ 12 chars = 96 bits — crackable with hashcat in under a minute
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("mysecretkey1"));

// ✅ 48 random bytes = 384 bits — not practically crackable
// Generate: openssl rand -base64 48
var key = new SymmetricSecurityKey(
    Convert.FromBase64String("t3O/wXzQkP5...your-48-byte-base64-key...=="));

Mistake 4: No Clock Skew Guard

❌ Vulnerable vs ✅ Fixed
// ❌ Default 5-minute clock skew effectively extends every token's lifetime by 5 minutes
// An "expired" token is still valid for 5 extra minutes by default
options.TokenValidationParameters = new() { ValidateLifetime = true };

// ✅ Tight skew — accept only 30 seconds of drift
options.TokenValidationParameters = new()
{
    ValidateLifetime = true,
    ClockSkew        = TimeSpan.FromSeconds(30)
};

Mistake 5: Returning 403 Instead of 404 for Owned Resources

❌ Vulnerable vs ✅ Fixed
// ❌ Returns 403 — tells attacker the note with ID 42 EXISTS (IDOR enumeration aid)
if (note.OwnerId != userId)
    return Results.Forbid();

// ✅ Returns 404 — attacker learns nothing
if (note.OwnerId != userId && !user.IsInRole("Admin"))
    return Results.NotFound();

Mistake 6: Accepting Tokens from Any Algorithm

❌ Vulnerable vs ✅ Fixed
// ❌ Default config can be fooled by "alg: none" attack on some older libs
options.TokenValidationParameters = new() { ValidateIssuerSigningKey = true };

// ✅ Explicitly whitelist the algorithms you accept
options.TokenValidationParameters = new()
{
    ValidateIssuerSigningKey = true,
    ValidAlgorithms          = new[] { SecurityAlgorithms.HmacSha256 },
    RequireSignedTokens      = true
};

Mistake 7: CSRF on JWT-Only Endpoints

Unnecessary vs Targeted CSRF Validation
// ❌ Validating CSRF on every endpoint breaks JWT clients for no security gain
app.UseAntiforgery(); // applied globally

// ✅ Only validate CSRF when the request is authenticated via cookie
var isCookieAuth = ctx.User.Identity?.AuthenticationType ==
                   CookieAuthenticationDefaults.AuthenticationScheme;
if (isCookieAuth)
    await antiforgery.ValidateRequestAsync(ctx);

Mistake 8: Logging Sensitive Claims

❌ Leaks PII to Logs vs ✅ Fixed
// ❌ Full user object logged — email, role, all claims end up in log files
logger.LogInformation("Request from user: {@User}", ctx.User);

// ✅ Log only the non-sensitive identifier
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
logger.LogInformation("Request from user {UserId}", userId);
Log Files Are a High-Value Target

Centralised logging systems (Splunk, Elastic, Datadog) aggregate logs from every service. If you log email addresses, tokens, or passwords, a compromise of your logging infrastructure becomes a user data breach. Log user IDs, correlation IDs, and operation names. Never log credentials, tokens, full request bodies, or PII in structured log fields.

End-to-End: Complete Program.cs

All patterns assembled into a single production-ready Program.cs. Every security layer is active and in the correct middleware order.

Complete Program.cs — Notes API
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

// ─── Validate secrets at startup ──────────────────────────────────────────
var jwtKey = builder.Configuration["Jwt:Key"]
    ?? throw new InvalidOperationException("Jwt:Key is required");
if (Encoding.UTF8.GetByteCount(jwtKey) < 32)
    throw new InvalidOperationException("Jwt:Key must be at least 32 characters");

// ─── CORS ─────────────────────────────────────────────────────────────────
var allowedOrigins = builder.Configuration
    .GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];

builder.Services.AddCors(o =>
{
    o.AddPolicy("AuthenticatedClients", p => p
        .WithOrigins(allowedOrigins)
        .AllowAnyHeader()
        .WithMethods("GET", "POST", "PUT", "DELETE")
        .AllowCredentials());

    o.AddPolicy("PublicRead", p => p
        .AllowAnyOrigin().WithMethods("GET"));
});

// ─── Authentication: JWT + Cookie ─────────────────────────────────────────
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new()
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer              = builder.Configuration["Jwt:Issuer"],
            ValidAudience            = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey         = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
            ClockSkew                = TimeSpan.FromSeconds(30),
            ValidAlgorithms          = [SecurityAlgorithms.HmacSha256]
        };
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
    {
        o.Cookie.Name        = "__Host-NotesAuth";
        o.Cookie.HttpOnly    = true;
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.Cookie.SameSite    = SameSiteMode.Strict;
        o.ExpireTimeSpan     = TimeSpan.FromHours(8);
        o.SlidingExpiration  = true;
    });

// ─── Authorization ────────────────────────────────────────────────────────
builder.Services.AddAuthorization(o =>
{
    o.DefaultPolicy = new AuthorizationPolicyBuilder()
        .AddAuthenticationSchemes(
            JwtBearerDefaults.AuthenticationScheme,
            CookieAuthenticationDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser()
        .Build();

    o.AddPolicy("AdminOnly",    p => p.RequireRole("Admin"));
    o.AddPolicy("PremiumOrAdmin", p => p
        .RequireAuthenticatedUser()
        .RequireAssertion(c =>
            c.User.IsInRole("Admin") ||
            c.User.HasClaim("subscription", "premium")));
});

// ─── Antiforgery ──────────────────────────────────────────────────────────
builder.Services.AddAntiforgery(o =>
{
    o.HeaderName         = "X-CSRF-TOKEN";
    o.Cookie.Name        = "__Host-csrf";
    o.Cookie.HttpOnly    = false;
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    o.Cookie.SameSite    = SameSiteMode.Strict;
});

// ─── Rate Limiting ────────────────────────────────────────────────────────
builder.Services.AddRateLimiter(o =>
{
    o.AddFixedWindowLimiter("auth-strict", opt =>
    {
        opt.PermitLimit = 5;
        opt.Window      = TimeSpan.FromMinutes(1);
        opt.QueueLimit  = 0;
    });
    o.AddPolicy("per-user", ctx =>
        RateLimitPartition.GetSlidingWindowLimiter(
            ctx.User.FindFirstValue(ClaimTypes.NameIdentifier)
                ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "anon",
            _ => new() { PermitLimit = 100, Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 6 }));
    o.OnRejected = async (ctx, ct) =>
    {
        ctx.HttpContext.Response.StatusCode  = 429;
        ctx.HttpContext.Response.ContentType = "application/problem+json";
        await ctx.HttpContext.Response.WriteAsJsonAsync(new
        {
            type   = "https://tools.ietf.org/html/rfc6585#section-4",
            title  = "Too Many Requests",
            status = 429,
            detail = "Rate limit exceeded."
        }, ct);
    };
});

// ─── HSTS ─────────────────────────────────────────────────────────────────
builder.Services.AddHsts(o =>
{
    o.Preload           = true;
    o.IncludeSubDomains = true;
    o.MaxAge            = TimeSpan.FromDays(365);
});

var app = builder.Build();

// ─── Middleware pipeline (order matters) ──────────────────────────────────
if (!app.Environment.IsDevelopment()) app.UseHsts();
app.UseHttpsRedirection();

// Security headers
app.Use(async (ctx, next) =>
{
    ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
    ctx.Response.Headers["X-Frame-Options"]         = "DENY";
    ctx.Response.Headers["Referrer-Policy"]         = "strict-origin-when-cross-origin";
    ctx.Response.Headers["Content-Security-Policy"] =
        "default-src 'self'; frame-ancestors 'none';";
    await next();
});

app.UseCors("AuthenticatedClients");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseAntiforgery();

// ─── Endpoints ────────────────────────────────────────────────────────────
app.MapPost("/auth/login/jwt", (LoginRequest req, IConfiguration cfg) =>
{
    var user = Store.Users.FirstOrDefault(u => u.Email == req.Email);
    if (user is null || !VerifyPassword(req.Password, user.PasswordHash))
        return Results.Problem(title: "Invalid credentials", statusCode: 401);

    var key    = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg["Jwt:Key"]!));
    var creds  = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    var claims = new Claim[]
    {
        new(JwtRegisteredClaimNames.Sub,   user.Id),
        new(JwtRegisteredClaimNames.Email, user.Email),
        new(ClaimTypes.Role,               user.Role),
        new(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString())
    };
    var token = new JwtSecurityToken(
        cfg["Jwt:Issuer"], cfg["Jwt:Audience"],
        claims, expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: creds);

    return Results.Ok(new { AccessToken = new JwtSecurityTokenHandler().WriteToken(token) });
})
.RequireRateLimiting("auth-strict")
.AllowAnonymous()
.WithTags("Auth");

app.MapGet("/notes", (ClaimsPrincipal user) =>
{
    var uid   = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    var notes = Store.Notes.Where(n => n.OwnerId == uid);
    return TypedResults.Ok(notes);
})
.RequireAuthorization()
.RequireRateLimiting("per-user")
.WithTags("Notes");

app.MapPost("/notes", async (
    CreateNoteRequest req,
    ClaimsPrincipal   user,
    IAntiforgery      af,
    HttpContext       ctx) =>
{
    if (ctx.User.Identity?.AuthenticationType ==
        CookieAuthenticationDefaults.AuthenticationScheme)
        await af.ValidateRequestAsync(ctx);

    var note = new Note
    {
        OwnerId = user.FindFirstValue(ClaimTypes.NameIdentifier)!,
        Title   = req.Title,
        Body    = req.Body
    };
    Store.Notes.Add(note);
    return TypedResults.Created($"/notes/{note.Id}", note);
})
.RequireAuthorization()
.RequireRateLimiting("per-user")
.WithTags("Notes");

app.MapDelete("/notes/{id}", (string id, ClaimsPrincipal user) =>
{
    var note = Store.Notes.FirstOrDefault(n => n.Id == id);
    if (note is null) return Results.NotFound();

    var uid     = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    var isAdmin = user.IsInRole("Admin");
    if (note.OwnerId != uid && !isAdmin) return Results.NotFound(); // 404 not 403

    Store.Notes.Remove(note);
    return Results.NoContent();
})
.RequireAuthorization()
.RequireRateLimiting("per-user")
.WithTags("Notes");

app.MapGet("/health", () => Results.Ok(new { status = "healthy" }))
   .RequireCors("PublicRead")
   .AllowAnonymous();

app.Run();

// ─── Helpers ──────────────────────────────────────────────────────────────
static bool VerifyPassword(string password, string hash)
{
    // Production: use PasswordHasher<T> or BCrypt
    return BCrypt.Net.BCrypt.Verify(password, hash);
}

Integration Test — Auth Flow

Integration Test — JWT Auth
public class AuthTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public AuthTests(WebApplicationFactory<Program> factory)
        => _client = factory.CreateClient();

    [Fact]
    public async Task Login_Returns_Token_On_Valid_Credentials()
    {
        var res = await _client.PostAsJsonAsync("/auth/login/jwt",
            new { Email = "test@example.com", Password = "Test123!" });

        res.EnsureSuccessStatusCode();
        var body = await res.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(string.IsNullOrEmpty(body.GetProperty("accessToken").GetString()));
    }

    [Fact]
    public async Task Notes_Returns_401_Without_Token()
    {
        var res = await _client.GetAsync("/notes");
        Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
        Assert.Equal("application/problem+json", res.Content.Headers.ContentType?.MediaType);
    }

    [Fact]
    public async Task Notes_Returns_403_For_Another_Users_Note()
    {
        var token  = await GetToken("user1@example.com", "Pass1!");
        var noteId = await CreateNote(token, "My Note", "Body");

        var token2  = await GetToken("user2@example.com", "Pass2!");
        _client.DefaultRequestHeaders.Authorization =
            new("Bearer", token2);

        var res = await _client.DeleteAsync($"/notes/{noteId}");

        // Should be 404, not 403 (don't confirm existence)
        Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
    }
}

Resources & Next Steps

You've built a Notes API with every production security layer wired correctly. Here's where to go deeper on each topic.

Official Documentation

Next Steps

Add refresh token rotation with a database-backed revocation store. Integrate ASP.NET Core Identity for production user management and password hashing. Add audit logging for security events (login, logout, failed auth, permission denied). Deploy behind a WAF (Cloudflare, Azure Front Door) for an additional rate-limiting and threat-detection layer. Consider OAuth2 / OpenID Connect with IdentityServer or Auth0 if you need third-party login or API delegation.

The security layers in this tutorial are independent: add them to any existing ASP.NET Core API incrementally. Start with the attack lab review — it costs nothing and often reveals issues already in production.

Frequently Asked Questions

Should I use JWT or cookies for my ASP.NET Core API?

It depends on your client. JWT bearer tokens are ideal for SPAs, mobile apps, and machine-to-machine scenarios where the client manages the token. Secure HttpOnly cookies are better for server-rendered apps and traditional browser flows because the browser handles storage automatically — eliminating the XSS risk of localStorage. Many production systems use both: cookies for browser clients, JWTs for API consumers.

Why is storing JWT in localStorage dangerous?

Any JavaScript running on your page can read localStorage, including injected third-party scripts and XSS payloads. If an attacker steals a JWT, they can impersonate the user from any machine until the token expires — there's no server-side way to invalidate it. Use HttpOnly cookies or in-memory storage instead. If you must use tokens client-side, keep expiry short and implement refresh token rotation.

Do I need CSRF protection for JWT bearer auth?

No. CSRF attacks exploit cookie-based authentication. If your API only accepts tokens via the Authorization header, the browser never sends them automatically, so CSRF doesn't apply. You only need CSRF protection when cookies carry your auth credentials. If you mix auth schemes, apply CSRF validation selectively to cookie-authenticated endpoints as shown in the tutorial.

How do I pick a rate limiting strategy?

Fixed window is simplest and predictable but allows bursting at window boundaries. Sliding window distributes requests more evenly. Token bucket handles bursty-but-bounded traffic well. For most APIs: use fixed window per IP for anonymous endpoints, sliding window per user for authenticated endpoints, and concurrency limiters for expensive operations. Always return 429 with a Retry-After header so clients can back off gracefully.

What CORS mistakes do developers make most often?

The most common mistake is setting AllowAnyOrigin() with AllowCredentials(), which ASP.NET Core rejects at runtime — but developers then work around it with SetIsOriginAllowed(_ => true), which is equally dangerous. Other mistakes include allowing wildcard origins in production, forgetting to restrict allowed methods and headers, and applying CORS globally when only specific endpoints need it. Always enumerate explicit allowed origins and review them per environment.

Back to Tutorials