ASP.NET Identity — Users, Roles, and Refresh Token Storage
How to configure ASP.NET Identity in Clean Architecture: custom AppUser, refresh token entity, Identity DbContext integration, role-based authorization, and the production pitfalls of token storage.
Where Identity Lives
ASP.NET Identity is an infrastructure concern. The domain knows nothing about users or tokens. The Application layer defines what a "current user" provides. Infrastructure implements the storage.
Domain: no knowledge of authentication
Application: ICurrentUser (read-only context), ITokenService (generates tokens)
Infrastructure: AppUser, AppDbContext extends IdentityDbContext,
TokenService implementation, RefreshToken entity
Api: authentication endpoints (Login, Register, RefreshToken) The AppUser Entity
// Infrastructure/Identity/AppUser.cs
using Microsoft.AspNetCore.Identity;
namespace SystemForge.Infrastructure.Identity;
public sealed class AppUser : IdentityUser
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string FullName => $"{FirstName} {LastName}".Trim();
public ICollection<RefreshToken> RefreshTokens { get; set; } = [];
}Refresh Token Entity
// Infrastructure/Identity/RefreshToken.cs
public sealed class RefreshToken
{
public int Id { get; set; }
public string UserId { get; set; } = string.Empty;
public AppUser User { get; set; } = null!;
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
public string? ReplacedByToken { get; set; }
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
public bool IsRevoked => RevokedAt is not null;
public bool IsActive => !IsRevoked && !IsExpired;
}Production issue I've seen: A system stored refresh tokens as a column on the user record (a single
RefreshTokenstring). When a user logged in from a second device, the first device's token was overwritten. Both devices needed to re-authenticate constantly, causing 401 errors on active sessions. The fix was a separateRefreshTokenstable with one row per issued token and revocation tracking.
DbContext Inheriting from IdentityDbContext
// Infrastructure/Persistence/AppDbContext.cs
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
public sealed class AppDbContext : IdentityDbContext<AppUser>, IUnitOfWork
{
private readonly IDomainEventPublisher _publisher;
public AppDbContext(
DbContextOptions<AppDbContext> options,
IDomainEventPublisher publisher)
: base(options)
{
_publisher = publisher;
}
public DbSet<Patient> Patients => Set<Patient>();
public DbSet<DrugOrder> DrugOrders => Set<DrugOrder>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder); // must call base — sets up Identity tables
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AssemblyReference).Assembly);
}
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var events = ChangeTracker.Entries<Entity>()
.Select(e => e.Entity)
.SelectMany(e => e.PopDomainEvents())
.ToList();
var rows = await base.SaveChangesAsync(ct);
if (events.Count > 0)
await _publisher.PublishAsync(events, ct);
return rows;
}
}ICurrentUser Interface (Application Layer)
// Application/Abstractions/ICurrentUser.cs
namespace SystemForge.Application.Abstractions;
public interface ICurrentUser
{
string? UserId { get; }
string? Email { get; }
string? FullName { get; }
bool IsAuthenticated { get; }
bool IsInRole(string role);
}
// Infrastructure/Identity/CurrentUser.cs
public sealed class CurrentUser : ICurrentUser
{
private readonly IHttpContextAccessor _httpContext;
public CurrentUser(IHttpContextAccessor httpContext) => _httpContext = httpContext;
private ClaimsPrincipal? User => _httpContext.HttpContext?.User;
public string? UserId => User?.FindFirstValue(ClaimTypes.NameIdentifier);
public string? Email => User?.FindFirstValue(ClaimTypes.Email);
public string? FullName => User?.FindFirstValue(ClaimTypes.Name);
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
public bool IsInRole(string role) => User?.IsInRole(role) ?? false;
}Token Service
// Application/Abstractions/ITokenService.cs
public interface ITokenService
{
string GenerateAccessToken(AppUser user, IList<string> roles);
RefreshToken GenerateRefreshToken();
ClaimsPrincipal? GetPrincipalFromExpiredToken(string token);
}
// Infrastructure/Identity/TokenService.cs
public sealed class TokenService : ITokenService
{
private readonly JwtSettings _jwtSettings;
public TokenService(IOptions<JwtSettings> options) => _jwtSettings = options.Value;
public string GenerateAccessToken(AppUser user, IList<string> roles)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Email, user.Email ?? string.Empty),
new(ClaimTypes.Name, user.FullName),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expiry = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpiryMinutes);
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: expiry,
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public RefreshToken GenerateRefreshToken() => new()
{
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)),
ExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays),
CreatedAt = DateTime.UtcNow,
};
public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token)
{
var validation = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = false, // allow expired — we're refreshing
ValidateIssuerSigningKey = true,
ValidIssuer = _jwtSettings.Issuer,
ValidAudience = _jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)),
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, validation, out var securityToken);
if (securityToken is not JwtSecurityToken jwtToken ||
!jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.OrdinalIgnoreCase))
return null;
return principal;
}
}DI Registration
// Infrastructure/DependencyInjection.cs
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Identity
services
.AddIdentity<AppUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ITokenService, TokenService>();
services.AddHttpContextAccessor();
services.Configure<JwtSettings>(configuration.GetSection("Jwt"));
return services;
}Configuration
// appsettings.json
{
"Jwt": {
"SecretKey": "your-very-long-secret-key-minimum-32-chars-in-production",
"Issuer": "SystemForge.Api",
"Audience": "SystemForge.Clients",
"AccessTokenExpiryMinutes": 15,
"RefreshTokenExpiryDays": 7
}
}PRO TIP: The
SecretKeyinappsettings.jsonis for development only. In production, inject it from Azure Key Vault, AWS Secrets Manager, or environment variables — never commit a production secret to source control.
Red Flag Answers
Red flag: "My refresh token is stored in a single column on the user table, overwritten on each login."
This breaks multi-device login. The fix is a separate table with one refresh token per session, plus revocation tracking (RevokedAt, ReplacedByToken).
Red flag: "I store the JWT secret in
appsettings.jsonand deploy that file to production."
Every developer who has ever cloned the repo knows the production secret. Rotate the key and move it to a secret manager.
Key Takeaway
ASP.NET Identity is infrastructure. The domain and application layers express authentication needs through interfaces (
ICurrentUser,ITokenService). Infrastructure wiresAppUser,IdentityDbContext, andJwtSecurityTokenHandlerbehind those interfaces. JWT access tokens are short-lived (15 minutes); refresh tokens are long-lived and stored in a database table with revocation tracking — one row per session, per device.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.