![How-to-Implement-JWT-Authentication-in-ASP.NET-Core]()
What We'll Build
We'll create:
Tech stack:
ASP.NET Core 8/9 style APIs
Entity Framework Core
SQL Server
ASP.NET Identity password hashing
JWT Bearer Authentication
JWT Architecture Overview
Flow:
User Login
↓
Validate Credentials
↓
Generate Access Token (Short-Lived)
↓
Generate Refresh Token (Long-Lived)
↓
Return Tokens to Client
↓
Client Calls Protected APIs
↓
Access Token Expires
↓
Client Sends Refresh Token
↓
Server Validates Refresh Token
↓
Issue New Access Token
Project Setup
Create project:
dotnet new webapi -n JwtAuthDemo
cd JwtAuthDemo
Install packages:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Identity
Project Structure
JwtAuthDemo
┣ Controllers
┃ ┗ AuthController.cs
┣ Data
┃ ┗ AppDbContext.cs
┣ Models
┃ ┣ User.cs
┃ ┣ RefreshToken.cs
┃ ┣ LoginRequest.cs
┃ ┗ RegisterRequest.cs
┣ Services
┃ ┣ ITokenService.cs
┃ ┗ TokenService.cs
┣ appsettings.json
┗ Program.cs
Step 1: Create Domain Models
User Entity
namespace JwtAuthDemo.Models;
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Email { get; set; } = default!;
public string PasswordHash { get; set; } = default!;
public string Role { get; set; } = "User";
public ICollection<RefreshToken> RefreshTokens { get; set; }
= new List<RefreshToken>();
}
Refresh Token Entity
Never store refresh tokens in plain text.
Store hashed values.
namespace JwtAuthDemo.Models;
public class RefreshToken
{
public Guid Id { get; set; } = Guid.NewGuid();
public string TokenHash { get; set; } = default!;
public DateTime ExpiresAtUtc { get; set; }
public bool IsRevoked { get; set; }
public DateTime CreatedAtUtc { get; set; }
public Guid UserId { get; set; }
public User User { get; set; } = default!;
}
Step 2: DTOs
Register Request
namespace JwtAuthDemo.Models;
public class RegisterRequest
{
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;
}
Login Request
namespace JwtAuthDemo.Models;
public class LoginRequest
{
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;
}
Token Response
namespace JwtAuthDemo.Models;
public class TokenResponse
{
public string AccessToken { get; set; } = default!;
public string RefreshToken { get; set; } = default!;
}
Step 3: Database Context
using JwtAuthDemo.Models;
using Microsoft.EntityFrameworkCore;
namespace JwtAuthDemo.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasIndex(x => x.Email)
.IsUnique();
base.OnModelCreating(modelBuilder);
}
}
Step 4: JWT Configuration
appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=JwtAuthDb;Trusted_Connection=True;TrustServerCertificate=True"
},
"Jwt": {
"Issuer": "JwtAuthDemo",
"Audience": "JwtAuthDemoUsers",
"SecretKey": "THIS_IS_FOR_DEV_ONLY_REPLACE_IN_PRODUCTION",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 7
}
}
Production Security Warning
Never store secrets in appsettings.json in production.
Use:
Azure Key Vault
AWS Secrets Manager
Docker secrets
Kubernetes secrets
environment variables
Example:
builder.Configuration["Jwt:SecretKey"];
Step 5: Token Service
Interface
using JwtAuthDemo.Models;
namespace JwtAuthDemo.Services;
public interface ITokenService
{
string GenerateAccessToken(User user);
string GenerateRefreshToken();
string HashToken(string token);
}
Implementation
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using JwtAuthDemo.Models;
using Microsoft.IdentityModel.Tokens;
namespace JwtAuthDemo.Services;
public class TokenService : ITokenService
{
private readonly IConfiguration _configuration;
public TokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateAccessToken(User user)
{
var jwtSection = _configuration.GetSection("Jwt");
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSection["SecretKey"]!));
var credentials = new SigningCredentials(
key,
SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var expires = DateTime.UtcNow.AddMinutes(
int.Parse(jwtSection["AccessTokenMinutes"]!));
var token = new JwtSecurityToken(
issuer: jwtSection["Issuer"],
audience: jwtSection["Audience"],
claims: claims,
expires: expires,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRefreshToken()
{
var bytes = RandomNumberGenerator.GetBytes(64);
return Convert.ToBase64String(bytes);
}
public string HashToken(string token)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(token));
return Convert.ToBase64String(hash);
}
}
Step 6: Password Hashing
Never store passwords directly.
Use ASP.NET Identity's password hasher.
using Microsoft.AspNetCore.Identity;
var passwordHasher = new PasswordHasher<User>();
Registration:
var user = new User
{
Email = request.Email
};
user.PasswordHash = passwordHasher.HashPassword(
user,
request.Password);
Verification:
var result = passwordHasher.VerifyHashedPassword(
user,
user.PasswordHash,
request.Password);
Step 7: Configure Authentication
Program.cs:
using System.Text;
using JwtAuthDemo.Data;
using JwtAuthDemo.Models;
using JwtAuthDemo.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
var jwt = builder.Configuration.GetSection("Jwt");
var key = Encoding.UTF8.GetBytes(jwt["SecretKey"]!);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = jwt["Issuer"],
ValidAudience = jwt["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(key),
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Why ClockSkew = Zero?
Default clock skew is 5 minutes.
That means expired tokens may still work.
Production APIs often require strict expiration.
ClockSkew = TimeSpan.Zero
Use only if your infrastructure time is synchronized.
Step 8: Authentication Controller
using JwtAuthDemo.Data;
using JwtAuthDemo.Models;
using JwtAuthDemo.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JwtAuthDemo.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITokenService _tokenService;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IConfiguration _configuration;
public AuthController(
AppDbContext db,
ITokenService tokenService,
IPasswordHasher<User> passwordHasher,
IConfiguration configuration)
{
_db = db;
_tokenService = tokenService;
_passwordHasher = passwordHasher;
_configuration = configuration;
}
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest request)
{
if (await _db.Users.AnyAsync(x => x.Email == request.Email))
return BadRequest("Email already exists.");
var user = new User
{
Email = request.Email
};
user.PasswordHash = _passwordHasher.HashPassword(
user,
request.Password);
_db.Users.Add(user);
await _db.SaveChangesAsync();
return Ok();
}
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request)
{
var user = await _db.Users
.Include(x => x.RefreshTokens)
.FirstOrDefaultAsync(x => x.Email == request.Email);
if (user is null)
return Unauthorized();
var result = _passwordHasher.VerifyHashedPassword(
user,
user.PasswordHash,
request.Password);
if (result == PasswordVerificationResult.Failed)
return Unauthorized();
var accessToken = _tokenService.GenerateAccessToken(user);
var refreshToken = _tokenService.GenerateRefreshToken();
var refreshDays = int.Parse(
_configuration["Jwt:RefreshTokenDays"]!);
user.RefreshTokens.Add(new RefreshToken
{
TokenHash = _tokenService.HashToken(refreshToken),
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = DateTime.UtcNow.AddDays(refreshDays)
});
await _db.SaveChangesAsync();
return Ok(new TokenResponse
{
AccessToken = accessToken,
RefreshToken = refreshToken
});
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh(TokenResponse request)
{
var tokenHash = _tokenService.HashToken(request.RefreshToken);
var refreshToken = await _db.RefreshTokens
.Include(x => x.User)
.FirstOrDefaultAsync(x => x.TokenHash == tokenHash);
if (refreshToken is null ||
refreshToken.IsRevoked ||
refreshToken.ExpiresAtUtc <= DateTime.UtcNow)
{
return Unauthorized();
}
refreshToken.IsRevoked = true;
var newAccessToken = _tokenService.GenerateAccessToken(
refreshToken.User);
var newRefreshToken = _tokenService.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken
{
UserId = refreshToken.UserId,
TokenHash = _tokenService.HashToken(newRefreshToken),
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = DateTime.UtcNow.AddDays(7)
});
await _db.SaveChangesAsync();
return Ok(new TokenResponse
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken
});
}
[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout(TokenResponse request)
{
var tokenHash = _tokenService.HashToken(request.RefreshToken);
var refreshToken = await _db.RefreshTokens
.FirstOrDefaultAsync(x => x.TokenHash == tokenHash);
if (refreshToken is not null)
{
refreshToken.IsRevoked = true;
await _db.SaveChangesAsync();
}
return Ok();
}
}
Step 9: Protected Endpoint
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JwtAuthDemo.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[Authorize]
[HttpGet]
public IActionResult Get()
{
return Ok("Protected data");
}
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
return Ok($"Deleted {id}");
}
}
Security Best Practices
1. Keep Access Tokens Short-Lived
Recommended:
5–15 minutes
Reason:
If stolen, exposure window stays small.
2. Use Refresh Token Rotation
Every refresh should:
revoke old token
issue new token
Prevents replay attacks.
3. Hash Refresh Tokens
Wrong:
Token = refreshToken
Correct:
TokenHash = HashToken(refreshToken)
4. Enforce HTTPS
Never allow JWT over HTTP.
app.UseHttpsRedirection();
options.RequireHttpsMetadata = true;
5. Validate Everything
Always validate:
issuer
audience
expiration
signing key
signature
6. Store Secrets Securely
Never:
"SecretKey": "production-secret"
Use secure secret stores.
7. Add Rate Limiting
Protect login endpoints.
Example:
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("auth", config =>
{
config.PermitLimit = 5;
config.Window = TimeSpan.FromMinutes(1);
});
});
Apply:
[EnableRateLimiting("auth")]
8. Revoke Tokens on Password Change
When password changes:
9. Add Device Metadata
Track:
IP
user agent
device id
creation time
Helps suspicious activity detection.
10. Avoid Sensitive Claims
Do NOT put:
passwords
PII
internal secrets
JWT payload is readable.
Common Production Mistakes
Using Long-Lived Access Tokens
Bad:
30 days
Good:
15 minutes
Storing Tokens in Local Storage
Risk:
XSS theft.
Better:
Using Weak Secrets
Bad:
mysecret123
Use:
256-bit+ cryptographic secret
Not Handling Revocation
Without revocation:
stolen refresh tokens remain valid.
Advanced Production Recommendations
Prefer Asymmetric Signing for Distributed Systems
Instead of:
HMAC shared secret
Use:
RSA / ECDSA
Benefits:
Add Token Versioning
User table:
public int TokenVersion { get; set; }
Claim:
new Claim("token_version", user.TokenVersion.ToString())
On password reset:
user.TokenVersion++;
Invalidates all tokens.
Centralized Revocation Cache
For scale:
Useful when multiple API nodes exist.
Testing with Swagger
Add:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.AddSecurityDefinition("Bearer", new()
{
Name = "Authorization",
Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = Microsoft.OpenApi.Models.ParameterLocation.Header
});
options.AddSecurityRequirement(new()
{
{
new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Reference = new Microsoft.OpenApi.Models.OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
Key Takeaways
Production JWT authentication requires more than token generation.
Essential checklist:
Final Thoughts
JWT authentication is simple to implement incorrectly and expensive to secure later.
If you're building production APIs in ASP.NET Core:
Design authentication as a security subsystem, not just middleware configuration.
That mindset prevents:
Happy Coding!
I write about modern C#, .NET, and real-world development practices. Follow me on C# Corner for regular insights, tips, and deep dives.