BlogImplementing JWT Authentication in ASP.NET Core
·Updated November 7, 2026·5 min read·Naima Tun Nissa

Implementing JWT Authentication in ASP.NET Core

Configure JWT authentication in ASP.NET Core with proper key storage, explicit algorithm pinning, tightened clock skew tolerance, and role-based authorization from claims.

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.JwtBearer

Generate 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:

---

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*

Advertisement

Written by

Naima Tun Nissa

Software Engineer Lead, Arcloops

Naima Tun Nissa is Software Engineer Lead at Arcloops, where she works on production authentication systems and backend implementation. She writes the framework and language-specific JWT implementation guides on this site.