• Home
  • /
  • Articles
  • /
  • Software Development
  • /
  • JWT Auth in ASP.NET Core with SQL Server Refresh Tokens
HEADLINES

JWT Authentication in ASP.NET Core With SQL Server-Backed Refresh Tokens

A complete walkthrough of issuing JSON Web Tokens in ASP.NET Core, storing and rotating refresh tokens per user (or per client) in SQL Server via EF Core, and revoking them safely — with full code.


  • Techm Studios
  • BY TECHM STUDIOS
  • |
  • Sept 10, 2026
  • |
  • UPDATED: Sept 10, 2026
  • |
  • 0 COMMENTS
JWT Auth in ASP.NET Core with SQL Server Refresh Tokens
© Milan Jovanovic / Images

ASP.NET Core · Authentication · SQL Server
.NET 8 / .NET 9+ EF Core SQL Server

01Access Tokens vs. Refresh Tokens

A JSON Web Token (JWT) is a signed, self-contained token that an API can validate without a database round-trip. In practice, two tokens work together:

  • Access token — a short-lived JWT (typically 5–30 minutes) sent with every API request in the Authorization: Bearer header. It's stateless and validated purely by signature and expiry.
  • Refresh token — a long-lived, opaque, random string used only to obtain a new access token once the old one expires, without forcing the user to log in again. Because it's powerful and long-lived, it must be stored server-side so it can be looked up, rotated, and revoked.
Key idea: ASP.NET Core Identity does not generate refresh tokens for you out of the box — it manages users, passwords, and cookie sessions, but JWT refresh-token issuance and storage is something you implement yourself (or via a library like OpenIddict). This article builds it directly with EF Core and SQL Server so you can see exactly what's happening.

02Project Setup

bash
dotnet new webapi -o JwtAuthApi
cd JwtAuthApi

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore

03Modeling Refresh Tokens in SQL Server

Store one row per issued refresh token, linked to the user (and optionally to a specific client/device), so each token can be individually looked up, rotated, and revoked:

C# — Models/RefreshToken.cs
public class RefreshToken
{
    public int Id { get; set; }
    public string UserId { get; set; } = default!;

    // Identifies which app/device issued this token — a web client,
    // a mobile app, a third-party API consumer, etc.
    public string ClientId { get; set; } = default!;

    public string TokenHash { get; set; } = default!;   // never store the raw token
    public DateTime ExpiresAtUtc { get; set; }
    public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
    public DateTime? RevokedAtUtc { get; set; }
    public string? ReplacedByTokenHash { get; set; }     // rotation chain
    public string? CreatedByIp { get; set; }

    public bool IsActive => RevokedAtUtc is null && DateTime.UtcNow < ExpiresAtUtc;

    public ApplicationUser User { get; set; } = default!;
}
Never store the raw refresh token. Store a SHA-256 hash of it (like a password), and compare hashes when a client presents a token. If your database is ever breached, the stored values are useless to an attacker without also knowing the original tokens.

04EF Core DbContext and Migration

C# — Data/ApplicationDbContext.cs
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options) { }

    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity<RefreshToken>(entity =>
        {
            entity.HasIndex(t => t.TokenHash).IsUnique();
            entity.HasIndex(t => new { t.UserId, t.ClientId });
            entity.HasOne(t => t.User)
                  .WithMany()
                  .HasForeignKey(t => t.UserId)
                  .OnDelete(DeleteBehavior.Cascade);
        });
    }
}
C# — Program.cs
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services
    .AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();
bash
dotnet ef migrations add InitialCreate
dotnet ef database update

05Configuring JWT Bearer Authentication

appsettings.json
{
  "Jwt": {
    "Issuer": "https://api.example.com",
    "Audience": "https://api.example.com",
    "SigningKey": "REPLACE_WITH_A_LONG_RANDOM_SECRET_AT_LEAST_32_BYTES",
    "AccessTokenMinutes": 15,
    "RefreshTokenDays": 14
  },
  "ConnectionStrings": {
    "Default": "Server=(localdb)\\MSSQLLocalDB;Database=JwtAuthDb;Trusted_Connection=True;"
  }
}
C# — Program.cs
var jwtSection = builder.Configuration.GetSection("Jwt");
var signingKey = new SymmetricSecurityKey(
    Encoding.UTF8.GetBytes(jwtSection["SigningKey"]!));

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwtSection["Issuer"],
            ValidateAudience = true,
            ValidAudience = jwtSection["Audience"],
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = signingKey,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30) // keep this small
        };
    });

builder.Services.AddAuthorization();

06Building a Token Service

C# — Services/ITokenService.cs
public interface ITokenService
{
    string GenerateAccessToken(ApplicationUser user, IEnumerable<Claim> extraClaims);
    (string rawToken, RefreshToken entity) GenerateRefreshToken(string userId, string clientId, string? ip);
    string Hash(string rawToken);
}
C# — Services/TokenService.cs
public class TokenService : ITokenService
{
    private readonly IConfiguration _config;
    private readonly SymmetricSecurityKey _signingKey;

    public TokenService(IConfiguration config)
    {
        _config = config;
        _signingKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!));
    }

    public string GenerateAccessToken(ApplicationUser user, IEnumerable<Claim> extraClaims)
    {
        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id),
            new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };
        claims.AddRange(extraClaims);

        var credentials = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(double.Parse(_config["Jwt:AccessTokenMinutes"]!)),
            signingCredentials: credentials);

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

    public (string rawToken, RefreshToken entity) GenerateRefreshToken(string userId, string clientId, string? ip)
    {
        var rawBytes = RandomNumberGenerator.GetBytes(64);
        var rawToken = Convert.ToBase64String(rawBytes);

        var entity = new RefreshToken
        {
            UserId = userId,
            ClientId = clientId,
            TokenHash = Hash(rawToken),
            ExpiresAtUtc = DateTime.UtcNow.AddDays(double.Parse(_config["Jwt:RefreshTokenDays"]!)),
            CreatedByIp = ip
        };

        return (rawToken, entity);
    }

    public string Hash(string rawToken)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
        return Convert.ToHexString(bytes);
    }
}

Register it in Program.cs: builder.Services.AddScoped<ITokenService, TokenService>();

07Login: Issuing Access + Refresh Tokens

C# — Controllers/AuthController.cs
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly ITokenService _tokenService;
    private readonly ApplicationDbContext _db;

    public AuthController(UserManager<ApplicationUser> userManager, ITokenService tokenService, ApplicationDbContext db)
    {
        _userManager = userManager;
        _tokenService = tokenService;
        _db = db;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginRequest request)
    {
        var user = await _userManager.FindByEmailAsync(request.Email);
        if (user is null || !await _userManager.CheckPasswordAsync(user, request.Password))
            return Unauthorized("Invalid credentials.");

        var roles = await _userManager.GetRolesAsync(user);
        var roleClaims = roles.Select(r => new Claim(ClaimTypes.Role, r));

        var accessToken = _tokenService.GenerateAccessToken(user, roleClaims);
        var (rawRefreshToken, refreshEntity) = _tokenService.GenerateRefreshToken(
            user.Id, request.ClientId, HttpContext.Connection.RemoteIpAddress?.ToString());

        _db.RefreshTokens.Add(refreshEntity);
        await _db.SaveChangesAsync();

        return Ok(new AuthResponse
        {
            AccessToken = accessToken,
            RefreshToken = rawRefreshToken,
            ExpiresAtUtc = refreshEntity.ExpiresAtUtc
        });
    }
}

request.ClientId identifies which app/device is logging in (e.g. "web-app", "ios-app", or a per-installation GUID), so the same user can hold multiple independent refresh tokens at once.

08Refreshing Tokens (With Rotation)

Refresh token rotation issues a brand-new refresh token every time one is used and immediately invalidates the old one. If a previously-used (already rotated) token is ever presented again, that's a strong signal of theft, and the whole token family should be revoked.

C#
[HttpPost("refresh")]
public async Task<IActionResult> Refresh(RefreshRequest request)
{
    var hash = _tokenService.Hash(request.RefreshToken);

    var stored = await _db.RefreshTokens
        .Include(t => t.User)
        .FirstOrDefaultAsync(t => t.TokenHash == hash);

    if (stored is null)
        return Unauthorized("Invalid refresh token.");

    if (stored.RevokedAtUtc is not null)
    {
        // Token reuse detected — revoke every active token for this user/client
        // as a precaution, since the token may have been stolen.
        var family = await _db.RefreshTokens
            .Where(t => t.UserId == stored.UserId && t.ClientId == stored.ClientId && t.RevokedAtUtc == null)
            .ToListAsync();

        foreach (var token in family) token.RevokedAtUtc = DateTime.UtcNow;
        await _db.SaveChangesAsync();

        return Unauthorized("Refresh token reuse detected; all sessions revoked.");
    }

    if (!stored.IsActive)
        return Unauthorized("Refresh token expired.");

    // Rotate: revoke the old token, issue a new one.
    var (newRawToken, newEntity) = _tokenService.GenerateRefreshToken(
        stored.UserId, stored.ClientId, HttpContext.Connection.RemoteIpAddress?.ToString());

    stored.RevokedAtUtc = DateTime.UtcNow;
    stored.ReplacedByTokenHash = newEntity.TokenHash;

    _db.RefreshTokens.Add(newEntity);
    await _db.SaveChangesAsync();

    var roles = await _userManager.GetRolesAsync(stored.User);
    var accessToken = _tokenService.GenerateAccessToken(
        stored.User, roles.Select(r => new Claim(ClaimTypes.Role, r)));

    return Ok(new AuthResponse
    {
        AccessToken = accessToken,
        RefreshToken = newRawToken,
        ExpiresAtUtc = newEntity.ExpiresAtUtc
    });
}

09Revoking Tokens (Logout, Per-Client, All Devices)

C#
// Log out one device/client
[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout(RevokeRequest request)
{
    var hash = _tokenService.Hash(request.RefreshToken);
    var token = await _db.RefreshTokens.FirstOrDefaultAsync(t => t.TokenHash == hash);

    if (token is null) return NotFound();

    token.RevokedAtUtc = DateTime.UtcNow;
    await _db.SaveChangesAsync();
    return NoContent();
}

// Revoke every active session for the current user (e.g. "sign out everywhere")
[Authorize]
[HttpPost("logout-all")]
public async Task<IActionResult> LogoutAll()
{
    var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)!;

    var tokens = await _db.RefreshTokens
        .Where(t => t.UserId == userId && t.RevokedAtUtc == null)
        .ToListAsync();

    foreach (var token in tokens) token.RevokedAtUtc = DateTime.UtcNow;
    await _db.SaveChangesAsync();
    return NoContent();
}

10Storing Tokens Per Client (Multi-Device / Multi-App)

Because each RefreshToken row carries both UserId and ClientId, the same user can be signed in on a phone, a browser, and a third-party integration simultaneously, each with its own independent, revocable refresh token:

C#
// List a user's active sessions/devices
[Authorize]
[HttpGet("sessions")]
public async Task<IActionResult> GetSessions()
{
    var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)!;

    var sessions = await _db.RefreshTokens
        .Where(t => t.UserId == userId && t.RevokedAtUtc == null && t.ExpiresAtUtc > DateTime.UtcNow)
        .Select(t => new { t.ClientId, t.CreatedAtUtc, t.CreatedByIp, t.ExpiresAtUtc })
        .ToListAsync();

    return Ok(sessions);
}

This makes it straightforward to build a "manage devices" screen, or to revoke a single stolen device's session without logging the user out everywhere.

11Cleaning Up Expired Tokens

Expired and revoked rows accumulate over time. A simple IHostedService running on a schedule keeps the table small:

C# — Services/RefreshTokenCleanupService.cs
public class RefreshTokenCleanupService : BackgroundService
{
    private readonly IServiceProvider _services;

    public RefreshTokenCleanupService(IServiceProvider services) => _services = services;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _services.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

            var cutoff = DateTime.UtcNow.AddDays(-30);
            await db.RefreshTokens
                .Where(t => t.ExpiresAtUtc < cutoff || (t.RevokedAtUtc != null && t.RevokedAtUtc < cutoff))
                .ExecuteDeleteAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
        }
    }
}

Register it once in Program.cs: builder.Services.AddHostedService<RefreshTokenCleanupService>();

12Security Best Practices

  • Never store raw refresh tokens — hash them (SHA-256 is sufficient since they're already high-entropy random values, unlike passwords).
  • Rotate refresh tokens on every use and detect reuse of a revoked token as a signal of possible theft, revoking the whole token family in response.
  • Keep access tokens short-lived (minutes, not hours) since they can't be revoked once issued — the refresh token is your actual point of control.
  • Store refresh tokens in an HttpOnly, Secure, SameSite cookie for browser clients rather than in JavaScript-accessible storage, to reduce exposure to XSS.
  • Scope tokens per client/device so a compromised device can be revoked without logging the user out everywhere.
  • Use a strong, sufficiently long signing key (32+ bytes for HMAC-SHA256, or prefer asymmetric RS256/ES256 keys if multiple services need to validate tokens without sharing a secret).
  • Set a small ClockSkew in token validation parameters; the default 5-minute skew is often more generous than necessary.
  • Index and periodically purge the refresh token table to keep lookups fast as your user base grows.
Written for ASP.NET Core (.NET 8 / .NET 9+) with EF Core and SQL Server. Code samples follow current Microsoft Learn conventions and common community patterns for refresh-token rotation, and are intended as a practical reference, not a verbatim reproduction of any single source.

Also Read: JWT vs API Keys for ASP.NET Core

SPONSORED ADS

COMMENTS
LEAVE A REPLY
SPONSORED ADS
POPULAR
AI-Generated Fashion Design

AI-generated fashion design is revolutionizing the industry, pushing the boundaries of creativity and innovation in style.


SPONSORED JOBS

TECHM JOBS
POPULAR
Journalist Jobs, South Africa

Browse most trending Journalist jobs in South Africa. Apply today

Photographer Jobs, South Africa

Browse most trending Photographer jobs in South Africa. Apply today

Graphical Designer Jobs, United Kingdom

Browse most trending Fashion jobs in United Kingdom. Apply today


TECHM PRODUCTS


TOP POSTS
STAY IN TOUCH
SPONSORED ADS
Subscribe to Updates

Get the latest creative news from FooBar about art, design and business.

Subscribe