If you've already wired up JWT bearer authentication in an ASP.NET Core Web API, you've solved maybe a third of the actual security problem. A working AddJwtBearer setup tells you the token was issued by your app and hasn't been tampered with.
It does not protect you from token theft, brute-force login attempts, an overly permissive CORS policy, or the fact that JWTs (JSON Web Tokens, a compact signed token format used to carry claims between parties) can't be revoked once issued without extra plumbing.
This guide picks up where the typical "add JWT auth in 20 minutes" tutorial stops. We'll cover the parts most walkthroughs skip: locking down signing keys, layering in ASP.NET Core's built-in rate limiting middleware, handling token revocation, and closing off common attack vectors like algorithm confusion and token replay. The target platform throughout is the current supported version of ASP.NET Core (.NET 8 LTS or newer), using the standard Microsoft.AspNetCore.Authentication.JwtBearer package that ships aligned to your .NET version.
Prerequisites
Before working through this guide, you should have:
- An ASP.NET Core Web API project already using controller-based or minimal API endpoints, targeting a current supported .NET version.
- Basic JWT authentication already configured with
AddAuthentication().AddJwtBearer(...), issuing tokens on login. - The
Microsoft.AspNetCore.Authentication.JwtBearerNuGet package installed, matching your project's .NET version. - Familiarity with claims-based authorization (granting access based on pieces of user data embedded in the token, called claims) and basic middleware pipeline concepts.
- A secret store for production use — Azure Key Vault, AWS Secrets Manager, or at minimum environment variables — rather than plaintext values in
appsettings.json.
If you haven't built the basic login-and-token-issuing flow yet, it's worth doing that first; this article assumes that scaffolding exists and focuses entirely on hardening it.
Step 1: Get the Signing Key and Token Validation Right
The single most common mistake in JWT setups isn't a missing feature — it's a validation configuration that's too loose. A lot of tutorials disable half the checks "to get it working" and never turn them back on.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = true;
options.SaveToken = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!)),
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 }
};
});
RequireHttpsMetadata should stay true in any real environment — turning it off is a debugging convenience that occasionally survives into production, which is bad. Note that this setting only has an effect when the handler is configured with an Authority to fetch signing keys or OIDC metadata from over HTTP(S); with a hardcoded SymmetricSecurityKey as shown here, there's no metadata endpoint being called, so the setting is inert but still worth leaving on in case the configuration changes later.
SaveToken is set to false because there's rarely a good reason to keep the raw token retrievable via HttpContext.GetTokenAsync once it's validated; leaving it there just increases the blast radius if something else on the request pipeline leaks the authentication ticket's stored properties.
The reduced ClockSkew is also intentional: the library default is five minutes, which is generous enough that a stolen, recently-expired token could still be replayed for a while after it should have died. Thirty seconds is enough to absorb normal clock drift between servers without extending a token's effective lifetime much beyond what it claims.
The ValidAlgorithms restriction deserves its own explanation, because it directly prevents a known JWT attack class called algorithm confusion. Some JWT libraries historically trusted the alg header inside the token itself to decide how to validate it. An attacker could craft a token with alg: none, or switch from an asymmetric algorithm (like RS256) to a symmetric one (HS256) and sign it using the public key as if it were a shared secret. Explicitly whitelisting the algorithms your API accepts closes that door regardless of what the token claims about itself.
For symmetric keys (HS256), the signing key should be at least 256 bits of high-entropy random data, stored outside source control. For anything beyond a small internal service, asymmetric signing (RS256 or ES256, using a public/private key pair) is worth the extra setup — the private key that signs tokens never needs to touch the API that validates them, which limits exposure if the validating service is compromised.
Step 2: Implement Refresh Token Rotation in ASP.NET Core
A long-lived access token is a liability: if it's stolen, it's valid until it expires, and there's no way to invalidate a stateless JWT on its own. The standard mitigation is short-lived access tokens (typically 5–15 minutes) paired with a longer-lived refresh token that's used to mint new access tokens.
public class RefreshToken
{
public int Id { get; set; }
public string UserId { get; set; } = default!;
public string TokenHash { get; set; } = default!;
public DateTime ExpiresAtUtc { get; set; }
public bool IsRevoked { get; set; }
public string? ReplacedByTokenHash { get; set; }
}
Storing only a hash of the refresh token (not the raw value) means that if your database is ever exposed, the tokens inside it aren't directly usable. When a client redeems a refresh token, you look up the hash, confirm it isn't expired or revoked, issue a new access token plus a new refresh token, then mark the old refresh token as replaced.
This pattern is called refresh token rotation, and it matters because it lets you detect reuse: if someone presents a refresh token that's already been rotated out, that's a strong signal it was stolen, and a reasonable response is to revoke the entire token family for that user.
if (storedToken.IsRevoked || storedToken.ExpiresAtUtc < DateTime.UtcNow)
{
return Results.Unauthorized();
}
if (storedToken.ReplacedByTokenHash is not null)
{
await revocationService.RevokeAllTokensForUserAsync(storedToken.UserId);
return Results.Unauthorized();
}
This is the piece most tutorials skip entirely, and it's arguably more important than the initial login flow — a login endpoint that issues a token safely is table stakes; handling what happens after that token might be compromised is where actual security work lives.
Step 3: Configure Rate Limiting for Auth Endpoints
JWT authentication doesn't do anything to stop brute-force password guessing or refresh-token enumeration attempts. ASP.NET Core has shipped built-in rate limiting middleware (Microsoft.AspNetCore.RateLimiting) since .NET 7, and it's a reasonable default for new projects since it avoids an extra dependency and integrates directly into the middleware pipeline.
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("LoginPolicy", limiterOptions =>
{
limiterOptions.PermitLimit = 5;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync(
"Too many attempts. Try again shortly.", cancellationToken);
};
});
var app = builder.Build();
app.UseRateLimiter();
app.MapPost("/auth/login", LoginHandler)
.RequireRateLimiting("LoginPolicy");
A fixed window limiter here caps login attempts to five per minute per matched policy. In practice, you'll usually want the limiter partitioned by client IP address or by the submitted username, rather than applying one global bucket to every caller — otherwise one aggressive attacker can lock out legitimate users sharing the same rate limit bucket. The PartitionedRateLimiter API supports this by keying limiter state off a value you extract from the request, such as context.Connection.RemoteIpAddress.
Applying this same pattern to your refresh-token endpoint is just as important as applying it to login — refresh endpoints are a common attacker target precisely because tutorials rarely rate-limit them.
Step 4: Lock Down CORS Instead of Allowing Everything
It's common to see AllowAnyOrigin() left in place from early development because it made local testing easier. In production, this means any website in the world can send authenticated requests from a user's browser to your API if it can get hold of a valid token — a meaningful risk if tokens are stored somewhere a browser script can reach, like localStorage.
builder.Services.AddCors(options =>
{
options.AddPolicy("ProductionPolicy", policy =>
{
policy.WithOrigins("https://app.yourdomain.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials();
});
});
app.UseCors("ProductionPolicy");
Note that AllowCredentials() cannot be combined with AllowAnyOrigin() — the CORS specification explicitly forbids it, and ASP.NET Core will throw an exception if you try. That restriction exists precisely to stop the kind of cross-origin credential leakage described above, so treat it as a guardrail rather than an obstacle to work around.
Where you store the JWT on the client also matters here. Storing it in localStorage makes it readable by any JavaScript running on the page, which means a cross-site scripting (XSS) vulnerability anywhere in your front end can exfiltrate it directly.
Storing it in an HttpOnly, Secure, SameSite cookie protects against that JavaScript access but introduces cross-site request forgery (CSRF) exposure instead, which then needs its own mitigation such as anti-forgery tokens. Neither option is free of trade-offs; pick based on your actual threat model rather than defaulting to whichever is more convenient to wire up.
One important gap to close if you go the cookie route: AddJwtBearer only looks for the token in the Authorization: Bearer <token> header by default. Switching to cookie storage without further configuration means the handler simply won't find the token, and every request will fail authentication. You need to explicitly read the token out of the cookie in the OnMessageReceived event:
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
context.Token = context.Request.Cookies["AccessToken"];
return Task.CompletedTask;
}
};
This goes inside the same AddJwtBearer(options => { ... }) block from Step 1. Without it, cookie-based storage and JWT bearer authentication simply don't talk to each other.
How It Works
Underneath all of this, the request pipeline is doing the same fundamental thing every time: extracting the bearer token from the Authorization header, verifying its signature against the trusted signing key, checking the issuer and audience claims match what's expected, confirming it hasn't expired, and only then populating HttpContext.User with the claims inside it.
Policy-based authorization (rules like RequireClaim or RequireRole evaluated against that populated user) runs after authentication succeeds, as a separate stage in the pipeline.
Rate limiting middleware runs earlier in the pipeline than authentication for endpoints like login where there's no token yet to authenticate against, because the developer calls app.UseRateLimiter() before app.UseAuthentication() in the pipeline — this ordering is a result of how those calls are arranged in Program.cs, not something the framework infers automatically per endpoint. That ordering matters: you want to reject excessive attempts before spending CPU cycles on password hashing or database lookups, not after.
Common Errors and Troubleshooting
401 Unauthorized immediately after what looks like a successful login. This is almost always a mismatch between the issuer or audience configured when the token was created and the values configured in TokenValidationParameters for validation. Double-check that Jwt:Issuer and Jwt:Audience in configuration are identical strings, including scheme and trailing slashes, in both the token-issuing code and the validation setup.
403 Forbidden despite a valid, authenticated token. A 403 means authentication succeeded but authorization failed — the user is who they say they are, but the policy or role check rejected them. Check that the claim your policy checks for (RequireClaim("permission", "orders.read"), for example) actually exists in the token with the exact claim type and value your policy expects; claim type strings are case-sensitive and easy to typo.
A SecurityTokenInvalidSigningKeyException is thrown on startup or on first token validation. This almost always means the configured HS256 signing key is shorter than 256 bits (32 bytes). Test or placeholder secrets typed by hand are a common cause — generate a proper high-entropy key rather than shortening the validation requirement to make the error go away.
Refresh tokens silently stop working after a server restart. This usually means refresh tokens or revocation state are being held in memory rather than persisted to a database. In-memory storage doesn't survive app restarts or work across multiple server instances behind a load balancer, so any production deployment needs persisted refresh token storage.
Rate limiting rejects legitimate traffic during load testing or from a shared corporate NAT. If your rate limiter partitions by IP address alone, users behind the same corporate network or VPN can end up sharing a rate limit bucket. Consider partitioning by a combination of IP and submitted username for login endpoints specifically, so one bad actor doesn't lock out everyone on the same network.
Best Practices
Keep access tokens short-lived, generally in the 5 to 15 minute range, and rely on refresh token rotation for longer sessions rather than extending access token lifetime for convenience. Store signing keys and connection strings in a proper secret manager rather than appsettings.json, even in a private repository — secrets in source control have a way of ending up somewhere they shouldn't over the life of a project.
Apply the OWASP API Security guidance as a baseline checklist rather than reinventing your own list from scratch; broken authentication and excessive data exposure are consistently among the most common API vulnerabilities documented there. Enforce HTTPS redirection unconditionally with app.UseHttpsRedirection(), and add standard security response headers — X-Content-Type-Options: nosniff, a reasonable Content-Security-Policy, and Strict-Transport-Security — since JWT auth alone says nothing about response-side hardening.
Rotate signing keys periodically, and support key rotation gracefully by validating against multiple currently-valid keys during a transition window rather than invalidating every outstanding token the moment you rotate. Log authentication failures and rate-limit rejections with enough context (timestamp, source IP, endpoint) to spot brute-force patterns, but never log the token itself or the raw password — auth logs are a common place sensitive data leaks unintentionally.
Finally, don't treat token validation, rate limiting, and CORS as independent checkboxes. They're layers of the same defense, and a gap in one weakens the others — an API with perfect JWT validation but no rate limiting on its login endpoint is still trivially brute-forceable.
Key Takeaways
- Should I store JWTs in localStorage or a cookie? localStorage is simpler but exposed to XSS; an HttpOnly cookie blocks that but needs CSRF protection and an
OnMessageReceivedhandler to work withAddJwtBearer. Choose based on your threat model, not convenience. - How long should an access token live? 5–15 minutes, paired with refresh token rotation for longer sessions.
- How do I detect a stolen refresh token? Rotate on every use and treat a reused (already-replaced) refresh token as a signal to revoke the whole token family.
- What stops algorithm confusion attacks? Explicitly restricting
ValidAlgorithmsrather than trusting the token's ownalgheader.
A minimal, non-sensitive configuration shape for the values referenced above — actual keys and secrets belong in a secret manager or environment variables, never in this file:
{
"Jwt": {
"Issuer": "https://api.yourdomain.com",
"Audience": "https://app.yourdomain.com"
}
}
Conclusion
Getting JWT bearer tokens issued and validated is the easy 20% of the problem; the parts that actually determine whether your API holds up under attack are algorithm restriction, short token lifetimes with rotation, rate limiting on auth endpoints, and a CORS policy that doesn't hand out credentials to any origin that asks.
Put together, these layers are what turn a working ASP.NET Core Web API JWT authentication setup into one that's actually production-ready rather than merely functional in a demo. You now have a concrete pattern for signing key configuration, refresh token rotation with reuse detection, built-in rate limiting middleware, and CORS lockdown that you can apply directly to an existing project. A reasonable next step is auditing your current API against the OWASP API Security Top 10 list to see which of these gaps you're still carrying.
