Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETASP.NET IdentityAuthenticationRefresh Tokens
Share:𝕏

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

C#
// 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

C#
// 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 RefreshToken string). 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 separate RefreshTokens table with one row per issued token and revocation tracking.


DbContext Inheriting from IdentityDbContext

C#
// 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)

C#
// 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

C#
// 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

C#
// 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

JSON
// 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 SecretKey in appsettings.json is 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.json and 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 wires AppUser, IdentityDbContext, and JwtSecurityTokenHandler behind 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.