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.
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:
Authorization: Bearer header. It's stateless and
validated purely by signature and expiry.
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
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.cspublic 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!;
}
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
{
"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();
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>();
[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.
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
});
}
// 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();
}
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:
// 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.
Expired and revoked rows accumulate over time. A simple IHostedService running on
a schedule keeps the table small:
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>();
HttpOnly, Secure,
SameSite cookie
for browser clients rather than in JavaScript-accessible
storage, to reduce exposure to XSS.
ClockSkew in token validation parameters; the
default 5-minute skew is often more generous than necessary.
Also Read: JWT vs API Keys for ASP.NET Core
AI-generated fashion design is revolutionizing the industry, pushing the boundaries of creativity and innovation in style.
Browse most trending Journalist jobs in South Africa. Apply today
Browse most trending Photographer jobs in South Africa. Apply today
Browse most trending Fashion jobs in United Kingdom. Apply today
AI-Generated Fashion Design
SEP 10, 2026
Streetwear Luxury Collaborations
SEP 10, 2026 5,617 VIEWS
Get the latest creative news from FooBar about art, design and business.
Subscribe