.NET Core  

Implementing JWT Authentication with Refresh Tokens in ASP.NET Core

Authentication is a critical part of any modern web application. In this article, we’ll build a secure JWT-based authentication system with Refresh Tokens using ASP.NET Core, Identity, and Dapper.

We’ll cover:

  • Login with JWT

  • Refresh token mechanism

  • Logout & token revocation

  • Change password with security stamp validation

πŸ“¦ NuGet Packages Used

Install these packages:


dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Dapper
dotnet add package System.IdentityModel.Tokens.Jwt

πŸ—οΈ Project Architecture

We are using a clean architecture approach:

API Layer (Controllers)
Application Layer (Interfaces)
Infrastructure Layer (Repositories, Services)
Persistence Layer (Dapper + EF Core)

πŸ”‘ JWT Configuration (appsettings.json)

"Jwt": {
  "Key": "THIS_IS_MY_SUPER_SECRET_KEY_123456789",
  "Issuer": "ProductAPI",
  "Audience": "ProductClient",
  "AccessTokenMinutes": 10,
  "RefreshTokenDays": 7
}

βš™οΈ Configure JWT Authentication

builder.Services.AddAuthentication(options => {
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(opt =>
{
    opt.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
    };

    opt.Events = new JwtBearerEvents
    {
        OnTokenValidated = async context =>
        {
            var userManager = context.HttpContext.RequestServices
                .GetRequiredService<UserManager<ApplicationUser>>();

            var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
            var tokenStamp = context.Principal?.FindFirstValue("securityStamp");

            var user = await userManager.FindByIdAsync(userId);

            if (user == null || user.SecurityStamp != tokenStamp)
            {
                context.Fail("Token is no longer valid.");
            }
        }
    };
});

πŸ‘‰ This ensures tokens become invalid if the user logs out or changes password.

πŸ” Login API

[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request)
{
    var user = await _userManager.FindByEmailAsync(request.Email);

    if (user == null || !await _userManager.CheckPasswordAsync(user, request.Password))
        return Unauthorized("Invalid credentials");

    var roles = await _userManager.GetRolesAsync(user);
    var role = roles.FirstOrDefault();

    var accessToken = GenerateAccessToken(user, role);
    var refreshToken = await CreateAndSaveRefreshTokenAsync(user.Id);

    return Ok(new { accessToken, refreshToken = refreshToken.Token });
}

🧾 Generate JWT Token

private string GenerateAccessToken(ApplicationUser user, string role)
{
    var claims = new[]
    {
        new Claim(JwtRegisteredClaimNames.Sub, user.Id),
        new Claim(ClaimTypes.NameIdentifier, user.Id),
        new Claim(ClaimTypes.Name, user.UserName ?? ""),
        new Claim(ClaimTypes.Role, role),
        new Claim("securityStamp", user.SecurityStamp ?? "")
    };

    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

    var token = new JwtSecurityToken(
        issuer: _config["Jwt:Issuer"],
        audience: _config["Jwt:Audience"],
        claims: claims,
        expires: DateTime.UtcNow.AddMinutes(10),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    return new JwtSecurityTokenHandler().WriteToken(token);
}

πŸ”„ Refresh Token API

[HttpPost("refresh")]
public async Task<IActionResult> Refresh(RefreshRequest request)
{
    var stored = await _authUserRepository.GetRefreshTokenAsync(request.RefreshToken);

    if (stored == null || stored.IsRevoked || stored.ExpiresOn < DateTime.UtcNow)
        return Unauthorized("Invalid or expired refresh token");

    var user = await _userManager.FindByIdAsync(stored.UserId);

    await _authUserRepository.RevokeRefreshTokenAsync(stored.Token);

    var newAccessToken = GenerateAccessToken(user, "User");
    var newRefreshToken = await CreateAndSaveRefreshTokenAsync(user.Id);

    return Ok(new
    {
        accessToken = newAccessToken,
        refreshToken = newRefreshToken.Token
    });
}

πŸšͺ Logout API

[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
    var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);

    await _authUserRepository.RevokeAllUserRefreshTokensAsync(userId);
    await _userManager.UpdateSecurityStampAsync(user);

    return Ok("Logged out successfully");
}

πŸ‘‰ This invalidates all tokens instantly.

πŸ”‘ Change Password API

[Authorize]
[HttpPost("change-password")]
public async Task<IActionResult> ChangePassword(ChangePasswordRequest request)
{
    var user = await _userManager.FindByIdAsync(userId);

    var result = await _userManager.ChangePasswordAsync(
        user,
        request.CurrentPassword,
        request.NewPassword
    );

    await _authUserRepository.RevokeAllUserRefreshTokensAsync(userId);
    await _userManager.UpdateSecurityStampAsync(user);

    return Ok("Password changed successfully");
}

πŸ”„ Refresh Token Implementation with Dapper & SQL Server in ASP.NET Core

In the previous section, we implemented JWT authentication. Now let’s complete the system by adding a Refresh Token mechanism using SQL Server and Dapper.

πŸ—„οΈ Database Table for Refresh Tokens

CREATE TABLE [dbo].[RefreshTokens]
(
    Id          INT IDENTITY(1,1) PRIMARY KEY,
    UserId      NVARCHAR(450)  NOT NULL,
    Token       NVARCHAR(200)  NOT NULL,
    ExpiresOn   DATETIME       NOT NULL,
    IsRevoked   BIT            NOT NULL DEFAULT 0,
    CreatedOn   DATETIME       NOT NULL DEFAULT GETUTCDATE()
)

βš™οΈ Stored Procedures

βœ… Save Refresh Token

CREATE PROCEDURE [dbo].[SaveRefreshToken]
    @UserId    NVARCHAR(450),
    @Token     NVARCHAR(200),
    @ExpiresOn DATETIME2
AS
BEGIN
    SET NOCOUNT ON;

    INSERT INTO RefreshTokens (UserId, Token, ExpiresOn, IsRevoked, CreatedOn)
    VALUES (@UserId, @Token, @ExpiresOn, 0, GETUTCDATE());
END

πŸ” Get Refresh Token

CREATE PROCEDURE [dbo].[GetRefreshToken]
    @Token NVARCHAR(200)
AS
BEGIN
    SET NOCOUNT ON;

    SELECT Id, UserId, Token, ExpiresOn, IsRevoked, CreatedOn
    FROM RefreshTokens
    WHERE Token = @Token
      AND IsRevoked = 0;
END

❌ Revoke Single Token

CREATE PROCEDURE [dbo].[RevokeRefreshToken]
    @Token NVARCHAR(200)
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE RefreshTokens
    SET IsRevoked = 1
    WHERE Token = @Token;
END

🚫 Revoke All Tokens for User

CREATE PROCEDURE [dbo].[RevokeAllUserRefreshTokens]
    @UserId NVARCHAR(450)
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE RefreshTokens
    SET IsRevoked = 1
    WHERE UserId = @UserId
      AND IsRevoked = 0;
END

🧹 Cleanup Expired Tokens (Optional but Recommended)

CREATE PROCEDURE [dbo].[CleanupExpiredRefreshTokens]
AS
BEGIN
    SET NOCOUNT ON;

    DELETE FROM RefreshTokens
    WHERE ExpiresOn < GETUTCDATE()
       OR IsRevoked = 1;
END

πŸ‘‰ You can schedule this using Hangfire / SQL Job.

πŸ“˜ Repository Interface

public interface IAuthUserRepository
{
    Task SaveRefreshTokenAsync(RefreshToken token);
    Task<RefreshToken?> GetRefreshTokenAsync(string token);
    Task RevokeRefreshTokenAsync(string token);
    Task RevokeAllUserRefreshTokensAsync(string userId);
}

⚑ Dapper Repository Implementation

public class AuthUserRepository : IAuthUserRepository
{
    private readonly DapperContext _context;

    public AuthUserRepository(DapperContext context)
    {
        _context = context;
    }

    public async Task SaveRefreshTokenAsync(RefreshToken token)
    {
        using var conn = _context.CreateConnection();

        await conn.ExecuteAsync("SaveRefreshToken", new
        {
            token.UserId,
            token.Token,
            token.ExpiresOn
        }, commandType: CommandType.StoredProcedure);
    }

    public async Task<RefreshToken?> GetRefreshTokenAsync(string token)
    {
        using var conn = _context.CreateConnection();

        return await conn.QueryFirstOrDefaultAsync<RefreshToken>(
            "GetRefreshToken",
            new { Token = token },
            commandType: CommandType.StoredProcedure);
    }

    public async Task RevokeRefreshTokenAsync(string token)
    {
        using var conn = _context.CreateConnection();

        await conn.ExecuteAsync(
            "RevokeRefreshToken",
            new { Token = token },
            commandType: CommandType.StoredProcedure);
    }

    public async Task RevokeAllUserRefreshTokensAsync(string userId)
    {
        using var conn = _context.CreateConnection();

        await conn.ExecuteAsync(
            "RevokeAllUserRefreshTokens",
            new { UserId = userId },
            commandType: CommandType.StoredProcedure);
    }
}
Tableswaggertable 2

πŸ” How It Works (Flow)

🟒 Login

  • User logs in

  • Access Token (short-lived) generated

  • Refresh Token saved in DB

πŸ”„ Refresh Token

  • Client sends refresh token

  • System validates:

    • Exists in DB

    • Not expired

    • Not revoked

  • Old token revoked

  • New access + refresh token issued

πŸšͺ Logout

  • All refresh tokens revoked

  • Security stamp updated

  • All JWT tokens become invalid

πŸ”‘ Password Change

  • Password updated

  • All tokens revoked

  • User forced to login again