DEV Community

ZèD
ZèD

Posted on • Edited on

Custom Role-Based Authorization with JWT in ASP.NET Core

This implementation outlines a practical authorization pattern for ASP.NET Core applications using JWT authentication plus dynamic role-based access control (RBAC). The goal is to keep authorization decisions centralized, route-aware, and auditable without smearing permission checks across controllers until the codebase starts negotiating with chaos.


1. Custom Authorization Requirement

Purpose: Represent the custom authorization requirement consumed by the policy pipeline

public sealed class RoutePermissionRequirement : IAuthorizationRequirement
{
    // Marker requirement only. Sometimes the framework just wants a shape, not a life story.
}
Enter fullscreen mode Exit fullscreen mode

Why this exists:

  • Keeps the policy definition aligned with ASP.NET Core authorization conventions
  • Gives the handler a strongly named requirement instead of a vague placeholder
  • Stays intentionally minimal because the real logic belongs in the handler

2. Public Endpoint Allowlist

Purpose: Centralize endpoints that bypass permission checks

public static class PublicApiEndpoints
{
    public static readonly HashSet<string> Routes =
        new(StringComparer.OrdinalIgnoreCase)
        {
            "Authenticate",
            "FetchUserDataByKey"
        };
}
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • Uses an explicit allowlist instead of accidental openness
  • Makes audits easier because exempt routes live in one place
  • Gives O(1)-style membership checks instead of reflection on every request

3. Core Authorization Handler

Purpose: Resolve the current route, extract the caller role, and verify route permission access

public sealed class RoutePermissionAuthorizationHandler
    : AuthorizationHandler<RoutePermissionRequirement>
{
    private readonly IDataRepository _dataRepository;
    private readonly ILogger<RoutePermissionAuthorizationHandler> _logger;

    public RoutePermissionAuthorizationHandler(
        IDataRepository dataRepository,
        ILogger<RoutePermissionAuthorizationHandler> logger)
    {
        _dataRepository = dataRepository;
        _logger = logger;
    }

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        RoutePermissionRequirement requirement)
    {
        try
        {
            if (context.Resource is not HttpContext httpContext ||
                IsPublicEndpoint(httpContext.Request.Path.Value))
            {
                context.Succeed(requirement);
                return;
            }

            if (!TryExtractPermissionContext(
                    httpContext,
                    out var normalizedRoute,
                    out var roleId))
            {
                context.Fail();
                return;
            }

            var isAuthorized =
                await _dataRepository.Permissions.CheckAccess(roleId, normalizedRoute);

            if (!isAuthorized)
            {
                context.Fail();
                return;
            }

            context.Succeed(requirement);
        }
        catch (Exception exception)
        {
            _logger.LogError(
                exception,
                "Route permission authorization failed unexpectedly.");
            context.Fail();
        }
    }

    private static bool TryExtractPermissionContext(
        HttpContext httpContext,
        out string normalizedRoute,
        out long roleId)
    {
        normalizedRoute = string.Empty;
        roleId = 0;

        var requestPath = httpContext.Request.Path.Value;

        if (string.IsNullOrWhiteSpace(requestPath) ||
            !long.TryParse(
                httpContext.User.FindFirstValue(ClaimTypes.Role),
                out roleId))
        {
            return false;
        }

        var routeSegments = requestPath.Split(
            '/',
            StringSplitOptions.RemoveEmptyEntries);

        normalizedRoute = routeSegments.Length >= 3
            ? $"{routeSegments.ElementAtOrDefault(1)}/{routeSegments.ElementAtOrDefault(2)}"
            : string.Empty;

        // Normalize only what the permission table cares about. Security logic loves stable input.
        return !string.IsNullOrEmpty(normalizedRoute);
    }

    private static bool IsPublicEndpoint(string? requestPath)
    {
        if (string.IsNullOrWhiteSpace(requestPath))
            return true;

        return PublicApiEndpoints.Routes.Any(route =>
            requestPath.Contains(route, StringComparison.OrdinalIgnoreCase));
    }
}
Enter fullscreen mode Exit fullscreen mode

What the handler does:

  1. Short-circuits authorization for known public endpoints
  2. Extracts the caller's role identifier from the JWT claim set
  3. Normalizes the request path into a permission key
  4. Checks the permission store asynchronously
  5. Fails closed on parsing or runtime errors

Implementation notes:

  • The handler treats authorization failures explicitly instead of relying on side effects
  • Logging only occurs for unexpected exceptions, not ordinary access denial
  • A HashSet keeps the allowlist centralized and avoids reflection-heavy request processing

4. Authorization Policy Registration

Purpose: Attach the custom requirement and handler to the ASP.NET Core authorization pipeline

public static class AuthorizationConfigurationExtensions
{
    public static IServiceCollection AddRoutePermissionAuthorization(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddScoped<IAuthorizationHandler, RoutePermissionAuthorizationHandler>();

        services.AddAuthorizationBuilder()
            .SetDefaultPolicy(new AuthorizationPolicyBuilder()
                .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
                .RequireAuthenticatedUser()
                .AddRequirements(new RoutePermissionRequirement())
                .Build());

        return services;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this setup works:

  • Registers the handler with the correct scoped lifetime
  • Applies JWT authentication and the custom permission requirement as the default policy
  • Keeps authorization wiring in one place instead of burying it in startup clutter

5. Security Behavior Summary

This design gives you a route-sensitive RBAC layer with a few practical advantages:

  1. Centralized Enforcement: Permission validation happens in one handler, not in every controller
  2. JWT-Based Identity Mapping: Role identifiers come directly from authenticated claims
  3. Database-Backed Permissions: Access rules can evolve without recompiling the application
  4. Public Route Isolation: Anonymous or open endpoints are clearly declared
  5. Fail-Closed Behavior: Missing route data, invalid claims, or runtime exceptions all deny access

6. Operational Considerations

  • Claim Design: Storing a numeric role ID in ClaimTypes.Role works, but it couples authorization to token shape
  • Route Normalization: The current route extraction assumes a stable URL structure; if routing changes, permissions can drift
  • Route Matching Strategy: The HashSet improves storage and maintenance, though substring matching still depends on path shape
  • Logging Strategy: Authorization denials may deserve structured audit logs separate from exception logs
  • Permission Caching: High-throughput APIs may need caching around permission lookups to avoid turning the database into a stress toy

Conclusion

This pattern provides a solid foundation for dynamic authorization in ASP.NET Core by combining JWT authentication, route-based permission resolution, and centralized policy enforcement. The result is a system that stays flexible enough for evolving permission models while remaining strict about access control outcomes.

It is a good fit for internal platforms, enterprise APIs, and systems with database-driven authorization rules. If the route model stays stable and permission checks remain well-audited, this approach scales far better than hand-written controller checks and far more gracefully than hoping developers remember security rules from memory.

Top comments (0)