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.
}
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"
};
}
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));
}
}
What the handler does:
- Short-circuits authorization for known public endpoints
- Extracts the caller's role identifier from the JWT claim set
- Normalizes the request path into a permission key
- Checks the permission store asynchronously
- 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
HashSetkeeps 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;
}
}
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:
- Centralized Enforcement: Permission validation happens in one handler, not in every controller
- JWT-Based Identity Mapping: Role identifiers come directly from authenticated claims
- Database-Backed Permissions: Access rules can evolve without recompiling the application
- Public Route Isolation: Anonymous or open endpoints are clearly declared
- 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.Roleworks, 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
HashSetimproves 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)