JWT Authentication Setup

SkillSecurity

Configures JWT Bearer authentication for .NET APIs. Includes token generation, validation, refresh tokens, and user context extraction from claims.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the JWT Authentication Setup skill

What this skill tells your AI

The instructions your AI receives, as published by ronnythedev/dotnet-clean-architecture-skills in skills/12-dotnet-jwt-authentication/SKILL.md and read by ahel’s review.

Overview

This skill implements JWT (JSON Web Token) authentication for .NET APIs:

  • Access Token - Short-lived JWT returned in response body
  • Refresh Token - Stored in HttpOnly cookie (secure, not accessible via JavaScript)
  • Options Pattern - Configurable expiration via JwtOptions
  • Token Rotation - New refresh token issued on each refresh
  • Security Audit - Comprehensive event tracking for compliance
  • Token generation - Create access and refresh tokens
  • Token validation - Validate incoming tokens
  • User context - Extract user info from claims

Quick Reference

ComponentPurposeLocation
IJwtServiceToken generation interfaceApplication/Abstractions
JwtServiceToken generation implementationInfrastructure/Authentication
JwtOptionsconfiguration (expiration, issuer, etc.)Infrastructure/Authentication
JwtBearerOptionsSetupConfigure JWT validationInfrastructure/Authentication
IUserContextCurrent user infoApplication/Abstractions
UserContextExtract from HttpContextInfrastructure/Authentication
IRefreshTokenRepositoryRefresh token storageDomain/Identity
CookieSettingsCookie configurationInfrastructure/Authentication

Authentication Structure

/Application/Abstractions/
├── Authentication/
│   ├── IJwtService.cs
│   ├── IUserContext.cs
│   ├── TokenResponse.cs
│   └── AuthenticationErrors.cs

/Infrastructure/
├── Authentication/
│   ├── JwtOptions.cs
│   ├── JwtService.cs
│   ├── JwtBearerOptionsSetup.cs
│   ├── UserContext.cs
│   ├── CookieSettings.cs
│   └── RefreshTokenCookieManager.cs

Template: JWT Configuration Options

// src/{name}.infrastructure/Authentication/JwtOptions.cs
namespace {name}.infrastructure.authentication;

public sealed class JwtOptions
{
    public const string SectionName = "Jwt";

    public string Issuer { get; init; } = string.Empty;
    public string Audience { get; init; } = string.Empty;
    public string SecretKey { get; init; } = string.Empty;
    public int AccessTokenExpirationMinutes { get; init; } = 60;
    public int RefreshTokenExpirationDays { get; init; } = 7;
    public CookieSettings Cookie { get; init; } = new();
}

public sealed class CookieSettings
{
    /// <summary>
    /// Name of the refresh token cookie
    /// </summary>
    public string Name { get; init; } = "X-Refresh-Token";

    /// <summary>
    /// Cookie domain (leave empty for current domain)
    /// </summary>
    public string? Domain { get; init; }

    /// <summary>
    /// Cookie path
    /// </summary>
    public string Path { get; init; } = "/api/v1/auth";

    /// <summary>
    /// SameSite policy (Strict recommended for healthcare)
    /// </summary>
    public SameSiteMode SameSite { get; init; } = SameSiteMode.Strict;

    /// <summary>
    /// Require HTTPS (always true in production)
    /// </summary>
    public bool SecureOnly { get; init; } = true;
}

appsettings.json

{
  "Jwt": {
    "Issuer": "your-app-name",
    "Audience": "your-app-name",
    "SecretKey": "your-secret-key-at-least-32-characters-long-for-security",
    "AccessTokenExpirationMinutes": 60,
    "RefreshTokenExpirationDays": 7,
    "Cookie": {
      "Name": "X-Refresh-Token",
      "Domain": "",
      "Path": "/api/v1/auth",
      "SameSite": "Strict",
      "SecureOnly": true
    }
  }
}

Template: JWT Service Interface

// src/{name}.application/Abstractions/Authentication/IJwtService.cs
using {name}.domain.users;

namespace {name}.application.abstractions.authentication;

public interface IJwtService
{
    /// <summary>
    /// Generate access and refresh tokens for a user
    /// </summary>
    TokenGenerationResult GenerateTokens(
        User user,
        IEnumerable<string> roles,
        IEnumerable<string>? permissions = null);

    /// <summary>
    /// Generate tokens with custom claims
    /// </summary>
    TokenGenerationResult GenerateTokens(
        Guid userId,
        string email,
        IEnumerable<string> roles,
        IDictionary<string, string>? additionalClaims = null);

    /// <summary>
    /// Hash a refresh token for secure database storage
    /// </summary>
    string HashRefreshToken(string refreshToken);

    /// <summary>
    /// Verify a plain refresh token against its hash
    /// </summary>
    bool VerifyRefreshToken(string plainToken, string hashedToken);

    /// <summary>
    /// Get access token expiration time
    /// </summary>
    DateTime GetAccessTokenExpiry();

    /// <summary>
    /// Get refresh token expiration time
    /// </summary>
    DateTime GetRefreshTokenExpiry();
}

Template: Token Response

// src/{name}.application/Abstractions/Authentication/TokenResponse.cs
namespace {name}.application.abstractions.authentication;

/// <summary>
/// Response containing access token (refresh token is set via HttpOnly cookie)
/// </summary>
public sealed record TokenResponse(
    string AccessToken,
    DateTime AccessTokenExpiration,
    string TokenType = "Bearer");

/// <summary>
/// Internal response including refresh token (for cookie setting)
/// </summary>
public sealed record TokenGenerationResult(
    string AccessToken,
    string RefreshToken,
    DateTime AccessTokenExpiration,
    DateTime RefreshTokenExpiration);

Template: JWT Service Implementation

// src/{name}.infrastructure/Authentication/JwtService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using {name}.application.Abstractions.Authentication;
using {name}.application.Abstractions.Clock;
using {name}.domain.identity;

namespace {name}.infrastructure.authentication;

internal sealed class JwtService : IJwtService
{
    private readonly JwtOptions _options;
    private readonly IDateTimeProvider _dateTimeProvider;
    private readonly SigningCredentials _signingCredentials;
    private readonly JwtSecurityTokenHandler _tokenHandler;

    public JwtService(
        IOptions<JwtOptions> options,
        IDateTimeProvider dateTimeProvider)
    {
        _options = options.Value;
        _dateTimeProvider = dateTimeProvider;

        var securityKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_options.SecretKey));

        _signingCredentials = new SigningCredentials(
            securityKey,
            SecurityAlgorithms.HmacSha256);

        _tokenHandler = new JwtSecurityTokenHandler();
    }

    public TokenGenerationResult GenerateTokens(
        User user,
        IEnumerable<string> roles,
        IEnumerable<string>? permissions = null)
    {
        var additionalClaims = new Dictionary<string, string>
        {
            ["name"] = $"{user.FirstName} {user.LastName}".Trim()
        };

        return GenerateTokensInternal(
            user.Id,
            user.Email,
            roles,
            permissions,
            additionalClaims);
    }

    public TokenGenerationResult GenerateTokens(
        Guid userId,
        string email,
        IEnumerable<string> roles,
        IDictionary<string, string>? additionalClaims = null)
    {
        return GenerateTokensInternal(userId, email, roles, null, additionalClaims);
    }

    private TokenGenerationResult GenerateTokensInternal(
        Guid userId,
        string email,
        IEnumerable<string> roles,
        IEnumerable<string>? permissions,
        IDictionary<string, string>? additionalClaims)
    {
        var now = _dateTimeProvider.UtcNow;
        var accessTokenExpiration = now.AddMinutes(_options.AccessTokenExpirationMinutes);
        var refreshTokenExpiration = now.AddDays(_options.RefreshTokenExpirationDays);

        // Generate access token
        var accessToken = GenerateAccessToken(
            userId,
            email,
            roles,
            permissions,
            additionalClaims,
            now,
            accessTokenExpiration);

        // Generate opaque refresh token
        var refreshToken = GenerateRefreshToken();

        return new TokenGenerationResult(
            accessToken,
            refreshToken,
            accessTokenExpiration,
            refreshTokenExpiration);
    }

    private string GenerateAccessToken(
        Guid userId,
        string email,
        IEnumerable<string> roles,
        IEnumerable<string>? permissions,
        IDictionary<string, string>? additionalClaims,
        DateTime now,
        DateTime expiration)
    {
        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new(JwtRegisteredClaimNames.Email, email),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new(JwtRegisteredClaimNames.Iat,
                new DateTimeOffset(now).ToUnixTimeSeconds().ToString(),
                ClaimValueTypes.Integer64)
        };

        // Add roles
        foreach (var role in roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }

        // Add permissions
        if (permissions is not null)
        {
            foreach (var permission in permissions)
            {
                claims.Add(new Claim("permission", permission));
            }
        }

        // Add additional claims
        if (additionalClaims is not null)
        {
            foreach (var (key, value) in additionalClaims)
            {
                if (!string.IsNullOrEmpty(value))
                {
                    claims.Add(new Claim(key, value));
                }
            }
        }

        var token = new JwtSecurityToken(
            issuer: _options.Issuer,
            audience: _options.Audience,
            claims: claims,
            notBefore: now,
            expires: expiration,
            signingCredentials: _signingCredentials);

        return _tokenHandler.WriteToken(token);
    }

    private static string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }

    public string HashRefreshToken(string refreshToken)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken));
        return Convert.ToHexString(bytes).ToLowerInvariant();
    }

    public bool VerifyRefreshToken(string plainToken, string hashedToken)
    {
        var computedHash = HashRefreshToken(plainToken);
        return string.Equals(computedHash, hashedToken, StringComparison.OrdinalIgnoreCase);
    }

    public DateTime GetAccessTokenExpiry()
    {
        return _dateTimeProvider.UtcNow.AddMinutes(_options.AccessTokenExpirationMinutes);
    }

    public DateTime GetRefreshTokenExpiry()
    {
        return _dateTimeProvider.UtcNow.AddDays(_options.RefreshTokenExpirationDays);
    }
}

Template: Refresh Token Cookie Manager

// src/{name}.infrastructure/Authentication/RefreshTokenCookieManager.cs
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

namespace {name}.infrastructure.authentication;

public interface IRefreshTokenCookieManager
{
    void SetRefreshTokenCookie(HttpResponse response, string refreshToken, DateTime expiry);
    string? GetRefreshTokenFromCookie(HttpRequest request);
    void ClearRefreshTokenCookie(HttpResponse response);
}

internal sealed class RefreshTokenCookieManager : IRefreshTokenCookieManager
{
    private readonly JwtOptions _options;

    public RefreshTokenCookieManager(IOptions<JwtOptions> options)
    {
        _options = options.Value;
    }

    public void SetRefreshTokenCookie(HttpResponse response, string refreshToken, DateTime expiry)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,                          // Not accessible via JavaScript (XSS protection)
            Secure = _options.Cookie.SecureOnly,      // HTTPS only
            SameSite = _options.Cookie.SameSite,      // CSRF protection
            Expires = expiry,
            Path = _options.Cookie.Path,
            Domain = string.IsNullOrEmpty(_options.Cookie.Domain)
                ? null
                : _options.Cookie.Domain,
            IsEssential = true                        // Required for GDPR compliance
        };

        response.Cookies.Append(_options.Cookie.Name, refreshToken, cookieOptions);
    }

    public string? GetRefreshTokenFromCookie(HttpRequest request)
    {
        return request.Cookies.TryGetValue(_options.Cookie.Name, out var token)
            ? token
            : null;
    }

    public void ClearRefreshTokenCookie(HttpResponse response)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Secure = _options.Cookie.SecureOnly,
            SameSite = _options.Cookie.SameSite,
            Expires = DateTime.UtcNow.AddDays(-1),    // Expire immediately
            Path = _options.Cookie.Path,
            Domain = string.IsNullOrEmpty(_options.Cookie.Domain)
                ? null
                : _options.Cookie.Domain
        };

        response.Cookies.Append(_options.Cookie.Name, string.Empty, cookieOptions);
    }
}

Template: Refresh Token Entity

// src/{name}.domain/identity/RefreshToken.cs
namespace {name}.domain.identity;

public sealed class RefreshToken
{
    public Guid Id { get; private set; }
    public Guid UserId { get; private set; }
    public string TokenHash { get; private set; } = string.Empty;
    public DateTime CreatedAt { get; private set; }
    public DateTime ExpiresAt { get; private set; }
    public DateTime? RevokedAt { get; private set; }
    public string? ReplacedByTokenHash { get; private set; }
    public string? DeviceInfo { get; private set; }
    public string? IpAddress { get; private set; }

    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsRevoked => RevokedAt.HasValue;
    public bool IsActive => !IsRevoked && !IsExpired;

    private RefreshToken() { }

    public static RefreshToken Create(
        Guid userId,
        string tokenHash,
        DateTime expiresAt,
        string? deviceInfo = null,
        string? ipAddress = null)
    {
        return new RefreshToken
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            TokenHash = tokenHash,
            CreatedAt = DateTime.UtcNow,
            ExpiresAt = expiresAt,
            DeviceInfo = deviceInfo,
            IpAddress = ipAddress
        };
    }

    public void Revoke(string? replacedByTokenHash = null)
    {
        RevokedAt = DateTime.UtcNow;
        ReplacedByTokenHash = replacedByTokenHash;
    }
}

Template: JWT Bearer Options Setup

// src/{name}.infrastructure/Authentication/JwtBearerOptionsSetup.cs
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace {name}.infrastructure.authentication;

internal sealed class JwtBearerOptionsSetup
    : IConfigureNamedOptions<JwtBearerOptions>
{
    private readonly JwtOptions _jwtOptions;

    public JwtBearerOptionsSetup(IOptions<JwtOptions> jwtOptions)
    {
        _jwtOptions = jwtOptions.Value;
    }

    public void Configure(JwtBearerOptions options)
    {
        Configure(JwtBearerDefaults.AuthenticationScheme, options);
    }

    public void Configure(string? name, JwtBearerOptions options)
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = _jwtOptions.Issuer,

            ValidateAudience = true,
            ValidAudience = _jwtOptions.Audience,

            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(_jwtOptions.SecretKey)),

            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero,  // No tolerance for expiration

            // Ensure we get the user ID from the token
            NameClaimType = "sub",
            RoleClaimType = "role"
        };

        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                if (context.Exception is SecurityTokenExpiredException)
                {
                    context.Response.Headers.Append(
                        "Token-Expired",
                        "true");
                }
                return Task.CompletedTask;
            },
            OnChallenge = context =>
            {
                // Custom response for 401
                return Task.CompletedTask;
            },
            OnForbidden = context =>
            {
                // Custom response for 403
                return Task.CompletedTask;
            }
        };
    }
}

Template: User Context Interface

// src/{name}.application/Abstractions/Authentication/IUserContext.cs
namespace {name}.application.abstractions.authentication;

public interface IUserContext
{
    /// <summary>
    /// Current authenticated user's ID
    /// </summary>
    Guid UserId { get; }

    /// <summary>
    /// Current user's email
    /// </summary>
    string Email { get; }

    /// <summary>
    /// Current user's organization ID
    /// </summary>
    Guid? OrganizationId { get; }

    /// <summary>
    /// Current user's roles
    /// </summary>
    IReadOnlyList<string> Roles { get; }

    /// <summary>
    /// Check if user is authenticated
    /// </summary>
    bool IsAuthenticated { get; }

    /// <summary>
    /// Check if user has a specific role
    /// </summary>
    bool IsInRole(string role);

    /// <summary>
    /// Get a custom claim value
    /// </summary>
    string? GetClaimValue(string claimType);
}

Template: User Context Implementation

// src/{name}.infrastructure/Authentication/UserContext.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using {name}.application.abstractions.authentication;

namespace {name}.infrastructure.authentication;

internal sealed class UserContext : IUserContext
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public UserContext(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    private ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;

    public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;

    public Guid UserId
    {
        get
        {
            var userIdClaim = User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
                ?? User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;

            return Guid.TryParse(userIdClaim, out var userId)
                ? userId
                : throw new InvalidOperationException("User ID not found in claims");
        }
    }

    public string Email
    {
        get
        {
            return User?.FindFirst(JwtRegisteredClaimNames.Email)?.Value
                ?? User?.FindFirst(ClaimTypes.Email)?.Value
                ?? string.Empty;
        }
    }

    public Guid? OrganizationId
    {
        get
        {
            var orgIdClaim = User?.FindFirst("organization_id")?.Value;
            return Guid.TryParse(orgIdClaim, out var orgId) ? orgId : null;
        }
    }

    public IReadOnlyList<string> Roles
    {
        get
        {
            return User?.FindAll(ClaimTypes.Role)
                .Select(c => c.Value)
                .ToList()
                ?? new List<string>();
        }
    }

    public bool IsInRole(string role)
    {
        return User?.IsInRole(role) ?? false;
    }

    public string? GetClaimValue(string claimType)
    {
        return User?.FindFirst(claimType)?.Value;
    }
}

Template: Authentication Registration

// src/{name}.infrastructure/Authentication/AuthenticationExtensions.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using {name}.application.abstractions.authentication;

namespace {name}.infrastructure.authentication;

public static class AuthenticationExtensions
{
    public static IServiceCollection AddJwtAuthentication(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Bind JWT options
        services.Configure<JwtOptions>(
            configuration.GetSection(JwtOptions.SectionName));

        // Register JWT Bearer authentication
        services
            .AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
            })
            .AddJwtBearer();

        // Configure JWT Bearer options
        services.ConfigureOptions<JwtBearerOptionsSetup>();

        // Register services
        services.AddHttpContextAccessor();
        services.AddScoped<IJwtService, JwtService>();
        services.AddScoped<IUserContext, UserContext>();

        return services;
    }
}

Template: Login Command Handler

// src/{name}.application/Users/Login/LoginUserCommandHandler.cs
using {name}.application.Abstractions.Authentication;
using {name}.application.Abstractions.Messaging;
using {name}.domain.abstractions;
using {name}.domain.identity;

namespace {name}.application.users.login;

public sealed record LoginUserCommand(
    string Email,
    string Password,
    string? DeviceInfo = null,
    string? IpAddress = null) : ICommand<TokenResponse>;

internal sealed class LoginUserCommandHandler
    : ICommandHandler<LoginUserCommand, TokenResponse>
{
    private readonly IUserRepository _userRepository;
    private readonly IPasswordHasher _passwordHasher;
    private readonly IJwtService _jwtService;
    private readonly IRoleRepository _roleRepository;
    private readonly IRefreshTokenRepository _refreshTokenRepository;
    private readonly IUnitOfWork _unitOfWork;

    public LoginUserCommandHandler(
        IUserRepository userRepository,
        IPasswordHasher passwordHasher,
        IJwtService jwtService,
        IRoleRepository roleRepository,
        IRefreshTokenRepository refreshTokenRepository,
        IUnitOfWork unitOfWork)
    {
        _userRepository = userRepository;
        _passwordHasher = passwordHasher;
        _jwtService = jwtService;
        _roleRepository = roleRepository;
        _refreshTokenRepository = refreshTokenRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<TokenResponse>> Handle(
        LoginUserCommand request,
        CancellationToken cancellationToken)
    {
        // Find user by email
        var user = await _userRepository.GetByEmailAsync(
            request.Email,
            cancellationToken);

        if (user is null)
        {
            return Result.Failure<TokenResponse>(UserErrors.InvalidCredentials);
        }

        // Verify password
        if (!_passwordHasher.Verify(request.Password, user.PasswordHash))
        {
            return Result.Failure<TokenResponse>(UserErrors.InvalidCredentials);
        }

        // Check if user is active
        if (!user.IsActive)
        {
            return Result.Failure<TokenResponse>(UserErrors.AccountDeactivated);
        }

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
72
Forks
15
Last commit
Jul 2026
Advanced
Catalog kind
skill
Gateway key
dotnet-jwt-authentication
Source
github.com/ronnythedev/dotnet-clean-architecture-skills