Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 031 / 100

Permission-based authorization: best practices explained

How I implemented fine-grained access control in a CRM.

Expona Admin8 min read

Permission-based authorization: best practices explained

We've all inherited that CRM where every developer had a different idea about how to lock down features. Some routes checked user roles, others checked a string that didn't match anything else, and nobody could explain why the export button disappeared for certain users. Permission-based authorization: best practices explained is about building a system that stays maintainable as your application scales from 10 users to 10,000. In this article, we'll walk through how to implement fine-grained access control using role-to-permission mapping, permission constants, dynamic policies, and request-scoped caching.

Why roles alone aren't enough

Early on, authorization seems simple: check if the user is an admin, manager, or sales rep, then grant or deny. But real business requirements break this model fast. A manager needs to assign leads to their team, but not to other teams. A sales rep can view deals they own, but not edit them. An accountant can export revenue reports but not customer data. Layering these checks into role-based guards becomes brittle.

Roles map to permissions; code checks permissions. This separation is the foundation. A role like "SalesManager" bundles multiple granular permissions: Leads.View, Leads.Assign, Deals.View, Deals.Edit, and Reports.Export. When business logic needs to enforce access, it checks for the specific permission, not the role. This way, if you create a new role or adjust an existing one, the authorization checks don't change.

Defining permissions as constants

The second layer is making permissions visible and consistent. We define them as constants, grouped by domain:

csharp
public static class Permissions
{
    public static class Leads
    {
        public const string View = "leads.view";
        public const string Create = "leads.create";
        public const string Assign = "leads.assign";
        public const string Delete = "leads.delete";
    }

    public static class Deals
    {
        public const string View = "deals.view";
        public const string Edit = "deals.edit";
        public const string Export = "deals.export";
        public const string Close = "deals.close";
    }

    public static class Reports
    {
        public const string View = "reports.view";
        public const string ViewSensitive = "reports.view_sensitive";
        public const string Export = "reports.export";
    }

    public static class Admin
    {
        public const string ManageUsers = "admin.manage_users";
        public const string ManageRoles = "admin.manage_roles";
    }
}

Now when you protect an endpoint, you use these constants. No magic strings scattered across controllers. No wondering if it's "deals_export" or "deals.export". This approach also makes it easy to audit which permissions exist and where they're used—just search the codebase for the constant name.

Store the role-to-permission mapping in your database, not in code. A simple table structure holds roles, permissions, and their associations. This lets you adjust permissions without redeploying, which is critical in production CRM environments where business rules shift mid-quarter.

A dynamic policy provider creates policies on demand

ASP.NET Core's authorization middleware expects policies to be registered at startup. The traditional approach is to hardcode them:

csharp
services.AddAuthorization(options =>
{
    options.AddPolicy("CanAssignLeads", policy =>
        policy.RequireClaim("permission", "leads.assign"));
});

// Then in your controller:
[Authorize(Policy = "CanAssignLeads")]
public IActionResult AssignLead(int leadId, int userId) { ... }

This works for a small set of static policies, but it doesn't scale. Every new permission requires code changes and a redeploy. A dynamic policy provider solves this by building policies at runtime based on the database.

Implement a custom IAuthorizationPolicyProvider that intercepts policy requests:

csharp
public class DynamicAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
    private readonly IServiceProvider _serviceProvider;

    public DynamicAuthorizationPolicyProvider(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public Task GetPolicyAsync(string policyName)
    {
        // If the policy name matches our permission constant, build it dynamically
        if (policyName.StartsWith("permission:"))
        {
            var permission = policyName.Substring("permission:".Length);
            var policy = new AuthorizationPolicyBuilder()
                .AddRequirements(new PermissionRequirement(permission))
                .Build();
            return Task.FromResult(policy);
        }

        return Task.FromResult(null);
    }

    public Task GetDefaultPolicyAsync()
    {
        return Task.FromResult(new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build());
    }

    public Task GetFallbackPolicyAsync()
    {
        return Task.FromResult(null);
    }
}

Now in your controller, you use a convention-based attribute:

csharp
[Authorize(Policy = "permission:leads.assign")]
public IActionResult AssignLead(int leadId, int userId)
{
    // Assign the lead
    return Ok();
}

The policy provider intercepts "permission:leads.assign", builds an authorization policy that checks for that permission, and the custom requirement handler validates it. When you add a new permission to the database, no code changes are needed. The system picks it up automatically.

Implementing a custom authorization handler

The handler is where the actual permission check happens. It queries your permission store and verifies that the current user has the required permission:

csharp
public class PermissionRequirement : IAuthorizationRequirement
{
    public string Permission { get; }

    public PermissionRequirement(string permission)
    {
        Permission = permission;
    }
}

public class PermissionHandler : AuthorizationHandler
{
    private readonly IPermissionService _permissionService;

    public PermissionHandler(IPermissionService permissionService)
    {
        _permissionService = permissionService;
    }

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        PermissionRequirement requirement)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (string.IsNullOrEmpty(userId))
        {
            context.Fail();
            return;
        }

        var hasPermission = await _permissionService
            .UserHasPermissionAsync(userId, requirement.Permission);

        if (hasPermission)
        {
            context.Succeed(requirement);
        }
        else
        {
            context.Fail();
        }
    }
}

Register both the policy provider and handler in your DI container:

csharp
services.AddSingleton();
services.AddScoped();
services.AddScoped();

Cache user permissions per request, not forever

Every authorization check hits the database. With dozens of endpoints and hundreds of concurrent requests in a production CRM, this becomes a bottleneck. The fix is caching, but you need to be strategic about scope and lifetime.

Cache permissions for the duration of a single request using IMemoryCache with a per-request key prefix:

csharp
public class PermissionService : IPermissionService
{
    private readonly IDbContext _dbContext;
    private readonly IMemoryCache _cache;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public PermissionService(
        IDbContext dbContext,
        IMemoryCache cache,
        IHttpContextAccessor httpContextAccessor)
    {
        _dbContext = dbContext;
        _cache = cache;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task UserHasPermissionAsync(string userId, string permission)
    {
        // Create a request-scoped cache key
        var cacheKey = $"user_permissions:{userId}:{_httpContextAccessor.HttpContext.TraceIdentifier}";

        if (!_cache.TryGetValue(cacheKey, out Dictionary userPermissions))
        {
            // Load all permissions for this user
            userPermissions = await _dbContext.UserRolePermissions
                .Where(x => x.UserId == userId)
                .Select(x => x.Permission.Name)
                .ToDictionaryAsync(p => p, _ => true);

            // Cache for this request only (using TraceIdentifier ensures it's request-unique)
            _cache.Set(cacheKey, userPermissions, TimeSpan.FromSeconds(1));
        }

        return userPermissions.ContainsKey(permission);
    }
}

This approach loads all of a user's permissions once per request, then any subsequent authorization checks within that request reuse the cached result. When the request ends, the cache entry expires. If the same user makes another request, their permissions are reloaded from the database (or loaded fresh from Redis if you're scaling across servers).

For larger applications, consider a hybrid approach: cache permissions in distributed Redis with a short TTL (60–300 seconds), and invalidate the cache when a role or permission assignment changes. This trades immediate consistency for better throughput.

Common mistakes

  • Caching permissions with no expiration: A user's permissions change, but they're still cached globally. They retain access they should have lost. Always set an expiration on permission caches, even if it's just 5 minutes.
  • Mixing roles and permissions in authorization checks: If your code checks both [Authorize(Roles = "Admin")] and [Authorize(Policy = "permission:admin.manage_users")] inconsistently, you'll have blind spots. Standardize on permission checks.
  • Forgetting to check permissions in business logic, not just controllers: Authorization attributes protect the route, but if your service layer is called from a background job or internal API, the permission check never runs. Validate permissions at the service layer too.
  • Storing permissions in JWT claims without refresh logic: JWTs are signed and immutable. If a user's permissions change and you embed them in the JWT, the user won't see the change until the token expires. Keep the JWT lightweight; pull permissions from a service on each request or cache them with a short TTL.
  • Over-complicating with hierarchical role inheritance: "Admin includes Manager, Manager includes Contributor" seems elegant but becomes a maintenance nightmare. Use flat permission sets tied to roles; if you need inheritance, build it into the permission-assignment logic, not the role model.

Key takeaways

  • Separate roles from permissions: roles are groupings, permissions are the actual access rules your code checks.
  • Use permission constants to avoid magic strings and make your authorization model auditable and refactorable.
  • Implement a dynamic policy provider so new permissions can be added without code changes or redeployment.
  • Cache permissions per request (or per few minutes in distributed caches) to avoid hammering your database on every authorization check.
  • Always validate permissions at the service layer, not just the controller layer, so background jobs and internal APIs respect access control.
  • Keep permission-to-role assignments in the database so business users can adjust them without engineering involvement.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles