ASP.NET Core  

How to Implement JWT Authentication in ASP.NET Core the Right Way

How-to-Implement-JWT-Authentication-in-ASP.NET-Core

What We'll Build

We'll create:

  • User registration

  • Secure login

  • JWT access token generation

  • Refresh token generation

  • Token refresh endpoint

  • Logout / token revocation

  • Protected API endpoints

  • Role-based authorization

  • Security hardening

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:

  • invalidate all refresh tokens

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:

  • secure HTTP-only cookies (for browser apps)

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:

  • easier key rotation

  • microservice trust separation

  • no shared symmetric secret everywhere

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:

  • Redis

  • distributed cache

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:

  • ✅ Strong password hashing

  • ✅ Short-lived access tokens

  • ✅ Refresh token rotation

  • ✅ Refresh token hashing

  • ✅ HTTPS only

  • ✅ Rate limiting

  • ✅ Claims authorization

  • ✅ Secret management

  • ✅ Revocation strategy

  • ✅ Token versioning

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:

  • replay attacks

  • token leakage

  • weak secret exposure

  • privilege escalation

  • long-lived credential abuse

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.