Implementing JWT Authentication in ASP.NET Core
ASP.NET Core's built-in JWT bearer middleware (Microsoft.AspNetCore.Authentication.JwtBearer) makes wiring up authentication look deceptively easy — a few lines in Program.cs and [Authorize] attributes start working. Most real issues show up in the details the quickstart tutorials skip: where the signing key actually comes from, what TokenValidationParameters silently defaults to, and a ClockSkew default that's five minutes longer than almost anyone expects.
Install the Package
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerGenerate a Dedicated Signing Key
Same rule as every other language: a JWT signing key must be cryptographically random bytes, not a typed string, and at least 256 bits (32 bytes) for HMAC-SHA256.
using System.Security.Cryptography;
byte[] key = RandomNumberGenerator.GetBytes(32); // 256-bit key, CSPRNG-backed
string keyBase64 = Convert.ToBase64String(key);RandomNumberGenerator.GetBytes (or the older RNGCryptoServiceProvider on earlier .NET versions) wraps the OS CSPRNG — the same entropy guarantee as crypto.randomBytes in Node or secrets.token_bytes in Python. Never use System.Random for this; it's a deterministic pseudo-random generator meant for simulations and games, not security.
Storing the Key
Use the .NET Secret Manager for local development (dotnet user-secrets set "Jwt:Key" "...") and a real secrets store — Azure Key Vault, AWS Secrets Manager, or environment variables injected at deploy time — in production. Never put a signing key in appsettings.json even if that file isn't committed; it's too easy for that assumption to quietly become false later. See how to store JWT secrets securely for the general hierarchy — it applies identically here.
Configuring the Middleware
var builder = WebApplication.CreateBuilder(args);
var key = Convert.FromBase64String(builder.Configuration["Jwt:Key"]!);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ClockSkew = TimeSpan.FromSeconds(30), // default is 5 minutes — see below
};
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();The Default Everyone Gets Surprised By: a 5-Minute `ClockSkew`
TokenValidationParameters.ClockSkew defaults to 5 minutes in ASP.NET Core — far more generous than the few seconds of real clock drift this setting exists to absorb (see JWT clock skew for why a small tolerance is needed at all). The practical effect: a token with a 5-minute exp can remain usable for up to 10 minutes by the time you account for the default skew window, which routinely confuses people testing short-lived token expiry ("I set a 5-minute token and it's still valid after 8 minutes — is this broken?"). It isn't broken; it's the default. Set ClockSkew explicitly to something small and intentional (30 seconds is reasonable) rather than leaving the framework default in place and being surprised by it later.
Also Explicitly Pin the Algorithm
TokenValidationParameters validates the issuer signing key, but make sure you're not accidentally accepting more algorithms than you intend. Set ValidAlgorithms explicitly:
options.TokenValidationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 };This closes the same algorithm-confusion risk covered for every other language in this series — a verifier that trusts whatever alg a token declares is vulnerable regardless of the runtime.
Issuing a Token
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
var tokenHandler = new JwtSecurityTokenHandler();
var signingKey = new SymmetricSecurityKey(key);
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, userId) },
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: credentials
);
string jwt = tokenHandler.WriteToken(token);Protecting Endpoints
app.MapGet("/api/profile", () => "secured data")
.RequireAuthorization();Or on a controller: [Authorize] at the class or action level, same as any other ASP.NET Core authorization policy.
Role-Based Authorization from Claims
Add a role claim at issuance:
claims: new[] {
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(ClaimTypes.Role, "admin"),
}Then restrict an endpoint to it without any extra wiring — ASP.NET Core's authorization
middleware already reads ClaimTypes.Role from the validated token automatically:
app.MapGet("/api/admin/reports", () => "admin-only data")
.RequireAuthorization(policy => policy.RequireRole("admin"));This is a common point of confusion the first time around: the role check isn't JWT-specific
code you write yourself — it's standard ASP.NET Core authorization operating on claims that
happen to have arrived via a validated JWT instead of a cookie-based identity.
Frequently Asked Questions
Why is my 5-minute token still valid after more than 5 minutes?
TokenValidationParameters.ClockSkew defaults to 5 minutes, which is added as tolerance on top of your actual exp. Set it explicitly to a smaller value (e.g. 30 seconds) if you need expiry to be enforced close to the literal time you set.
HS256 or RS256 for an ASP.NET Core API?
If a single service both issues and verifies tokens, HS256 with a dedicated secret is simpler and equally secure. Move to asymmetric signing (RS256/ES256) once something besides your own service needs to verify tokens independently — see HS256 vs RS256.
Does `ValidateIssuerSigningKey = true` alone pin the algorithm?
No — that flag confirms the key used to sign is one you trust, but doesn't by itself restrict which algorithms are accepted. Set ValidAlgorithms explicitly as shown above.
Can I use `IConfiguration` to bind the signing key directly from `appsettings.json`?
You can technically bind the *reference* (a key name or path) from appsettings.json, but the actual key value should come from user secrets locally and a real secrets manager in production — never a plaintext value sitting in a config file in your repo.
What's the ASP.NET Core equivalent of refresh tokens?
There's no built-in refresh token implementation in the JWT bearer middleware itself — you implement it the same way as any other stack: issue a separate, longer-lived refresh token, store it server-side (so it can be revoked), and exchange it for a new access token via your own endpoint. See Access Token vs Refresh Token.
Related Implementations
Language-specific JWT secret patterns for other stacks:
- Implementing JWT Authentication in Node.js
- Secure JWT Configuration in Python FastAPI
- How to Store JWT Secrets in Go
- Spring Boot Security: Managing JWT Signing Keys
- Implementing JWT Authentication in Ruby on Rails
---
Generate a secure JWT secret for your ASP.NET Core app now: our free JWT Secret Generator runs entirely in your browser.
*Related: HS256 vs RS256 · JWT Clock Skew Debugging · How to Store JWT Secrets Securely*