Authorization
How an app decides what an already authenticated user may do: roles and claims, policies with requirements and handlers, access checks on a specific object, the ABAC and ReBAC models, and how a centralized access management system is built. Interviews use this topic to test both ASP.NET Core knowledge and the ability to design permissions for dozens of apps.
Before this topic: Web security basics, JWT and API protection.
What is role-based access control (RBAC) and how does it look in ASP.NET Core?
Section titled “What is role-based access control (RBAC) and how does it look in ASP.NET Core?”What it is
RBAC is a two-step access model: permissions are grouped into roles, and users are assigned roles. The “Accountant” role grants viewing and posting payments, so a new accountant gets that one role instead of twenty permissions.
Why
Access is managed by groups instead of user by user, and a role’s contents show who can do what.
How to use
[Authorize]— signed-in users only.[Authorize(Roles = "Admin,Manager")]— the Admin or the Manager role.- Two
[Authorize(Roles = ...)]attributes with different roles — both are required. [AllowAnonymous]— access without sign-in: the login page.- In Minimal APIs —
RequireAuthorization()andAllowAnonymous()on an endpoint or aMapGroupgroup.
Common mistake
Forgetting [Authorize] on a new controller: an endpoint without the attribute is open to everyone. A fallback policy that requires sign-in wherever nothing else is said protects against this.
Takeaway: permissions go into roles, roles go to users, and ASP.NET Core checks them with an attribute or RequireAuthorization.
How it works
- Authentication fills
HttpContext.User; the roles in it are claims of a special type. - The authorization middleware combines the endpoint metadata —
[Authorize],RequireAuthorization— into one policy. Rolesbecomes a requirement:User.IsInRoleis true for at least one role in the list.- If the requirement fails, an anonymous user gets a challenge (a prompt to sign in) and a signed-in user gets a forbid (a denial).
Which policy applies
| Endpoint metadata | What is checked |
|---|---|
| none | the fallback policy; by default there is none, so access is open |
[Authorize], RequireAuthorization() |
the default policy: the user is signed in |
Roles = "..." or Policy = "..." |
the roles or the named policy |
[AllowAnonymous], AllowAnonymous() |
nothing: there will be no denial |
Pitfalls
[AllowAnonymous]wins. On a controller it cancels every[Authorize]on its actions.- Case. Roles are compared case-sensitively:
Adminandadminare different roles. - Strings in code. The compiler won’t catch a typo in a role name, and renaming a role turns into a search across the whole project.
Rule of thumb
A fallback policy with RequireAuthenticatedUser, an explicit AllowAnonymous for public endpoints, and policy names instead of role lists in attributes.
Takeaway: no metadata — the fallback policy, [Authorize] — the default policy, Roles — an IsInRole check.
Under the hood
- The NIST RBAC model (the ANSI INCITS 359 standard) consists of a core level (users, roles, permissions, sessions), role hierarchies and constraints: static and dynamic separation of duties. ASP.NET Core provides only the core level; the rest is built outside it.
Rolesbecomes aRolesAuthorizationRequirement, andIsInRolelooks in each identity for a claim of that identity’sRoleClaimType. If the provider puts roles in a claim with a different name,IsInRolereturnsfalseeven though the roles are right.
Trade-offs
- Role or permission in code.
[Authorize(Roles = "HR,Admin")]mixes “who” with “what is allowed”. ACanApproveVacationpolicy keeps the role list in one place, and a new role needs no controller changes. - Roles in a token or cookie. The check costs nothing, but a removed role keeps working until the token is reissued or the session ends.
- Secure by default. A fallback policy turns a forgotten attribute into a denial. The price is that every public spot must be marked explicitly: sign-in, health checks, public pages and their static files.
How to check in production
- An integration test built on
WebApplicationFactorywalksEndpointDataSourceand fails if an endpoint has neither authorization metadata norAllowAnonymous. - On a denial, the
Microsoft.AspNetCore.Authorizationlog category writes “Authorization failed” and the list of unmet requirements.
Takeaway: [Authorize(Roles)] is core RBAC over claims; in production you add a fallback policy, policies instead of role strings, and awareness that roles in a token go stale.
using System.Security.Claims;using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie(); // the only scheme becomes the default schemebuilder.Services.AddAuthorizationBuilder() // Everything not marked explicitly requires sign-in .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
var app = builder.Build();
// Public endpoint: signs in a test user with the Manager roleapp.MapGet("/login", () => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.Name, "alice"), new Claim(ClaimTypes.Role, "Manager")], "Cookies")))) .AllowAnonymous();
app.MapGet("/profile", (ClaimsPrincipal user) => user.Identity!.Name); // fallback: any signed-in user
// Attribute on a lambda: Admin OR Manager — alice gets inapp.MapGet("/reports", [Authorize(Roles = "Admin,Manager")] () => "reports");
// Every endpoint in the group needs the Admin role — alice is deniedvar admin = app.MapGroup("/admin").RequireAuthorization(p => p.RequireRole("Admin"));admin.MapGet("/users", () => "users");
app.Run();- How do you require two roles at once? — Two
[Authorize(Roles = ...)]attributes or twoRequireRolecalls in a policy; a comma-separated list means OR. - How does the default policy differ from the fallback policy? — The default applies to a bare
[Authorize], the fallback applies to endpoints with no authorization metadata. - Which wins:
[Authorize]on the controller or[AllowAnonymous]on the action? —[AllowAnonymous]: with it,[Authorize]attributes are ignored. - Why does
IsInRolereturnfalsewhen the token has the role? — The role claim arrived with a different type than the identity’sRoleClaimType; this is configured in the authentication scheme. - How do you protect a whole group of endpoints? —
MapGroup(...).RequireAuthorization(...)in Minimal APIs or an attribute on the controller.
What is claims-based identity?
Section titled “What is claims-based identity?”What it is
- Claim — a type-value pair about the user:
name = alice,department = HR,role = Manager. ClaimsIdentity— the claims obtained through one sign-in method: a cookie, a token or Windows.ClaimsPrincipal— the user as a whole, one or more identities. This isHttpContext.User.
Where they come from
Whoever authenticated the user issues them. A login form writes claims into a cookie, an identity server puts them in a token, and Windows authentication takes them from the domain account.
How to use
User.Identity.Name— the name,User.IsInRole("Admin")— a role.User.FindFirst(ClaimTypes.Email)?.Value— a claim’s value.User.HasClaim("department", "HR")— a value check.
Common mistake
Putting fast-changing data in claims: remaining vacation days, a list of requests. It won’t update until the user signs in again.
Takeaway: claims are statements about the user, and a role is one of them.
How it works
| Type | What it holds |
|---|---|
Claim |
Type, Value, ValueType and Issuer — who issued it |
ClaimsIdentity |
claims from one authentication, AuthenticationType, NameClaimType, RoleClaimType |
ClaimsPrincipal |
a list of identities; the Claims property returns the claims of all of them |
- An identity counts as authenticated if its
AuthenticationTypeis set.new ClaimsIdentity(claims)without it is an anonymous user with claims. - Claim types are long URIs from
ClaimTypesor short JWT and OIDC names:sub,email,role. The authentication scheme decides how one maps to the other.
Roles are a special case
IsInRole("Admin") looks in each identity for a claim of that identity’s RoleClaimType with the value Admin. [Authorize(Roles)] and RequireRole work through it.
Adding claims
After successful authentication ASP.NET Core calls IClaimsTransformation.TransformAsync. That is where app data is added: an internal employee id, a department from the HR system.
Pitfalls
TransformAsynccan run several times per request: add a claim only if it isn’t there yet.- The result isn’t saved to the cookie and is recomputed on every request, so database calls inside it are cached.
- Claim values are compared case-sensitively.
Rule of thumb
Stable identity facts go in the token or cookie; app data is added inside the app.
Takeaway: a principal is a set of identities, an identity is a set of claims, and a role is a claim that IsInRole reads.
Under the hood
AuthenticationService.AuthenticateAsynccalls the scheme handler and thenIClaimsTransformation. The authentication middleware does this for the default scheme, and a policy with explicitAuthenticationSchemesdoes it again for its own schemes.- ASP.NET Core Identity builds claims at sign-in through
IUserClaimsPrincipalFactory<TUser>. After that they live in the cookie and change only when the cookie is reissued, for example by the security stamp validator — every 30 minutes by default. WindowsPrincipalandGenericPrincipalderive fromClaimsPrincipal, so every sign-in method gives the app the same API.
Cost
- Every claim makes the cookie or token bigger, and they travel with every request. ASP.NET Core splits a large cookie into chunks (
.AspNetCore.CookiesC1,C2), but headers still hit proxy and server limits. - A transformation that queries the database adds latency to every request, so the result is cached per user id with a short TTL.
Trade-offs
| Data source for the decision | Pro | Con |
|---|---|---|
| claims in a cookie or token | no network calls | stale until reissued |
| transformation with a cache | fresh app data | a cache or database hit per request |
| a query at check time | exact decision on the object | latency at check time |
How to check in production
- An admin-only endpoint that returns
User.Claimsshows the types, the issuers and what the transformation added. - The size of the
CookieandAuthorizationheaders in proxy logs is an early signal that claims have grown out of hand.
Takeaway: claims are a snapshot of the issuer’s statements; keep stable data in them, and add changing data through a cached transformation or check it at decision time.
using System.Security.Claims;using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();builder.Services.AddAuthorizationBuilder() .AddPolicy("HrOnly", p => p.RequireClaim("department", "HR"));builder.Services.AddSingleton<EmployeeDirectory>();builder.Services.AddTransient<IClaimsTransformation, DepartmentClaimsTransformation>();
var app = builder.Build();
// Only the name and role go into the cookieapp.MapGet("/login", () => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.Name, "alice"), new Claim(ClaimTypes.Role, "Manager")], "Cookies"))));
// name, role and the department added by the transformationapp.MapGet("/me", (ClaimsPrincipal user) => user.Claims.Select(c => new { c.Type, c.Value })) .RequireAuthorization();app.MapGet("/hr/vacancies", () => "vacancies").RequireAuthorization("HrOnly"); // alice is in HR — allowed
app.Run();
public class EmployeeDirectory{ public string GetDepartment(string userName) => userName == "alice" ? "HR" : "IT";}
// Runs after every successful authentication, sometimes several times per requestpublic class DepartmentClaimsTransformation(EmployeeDirectory directory) : IClaimsTransformation{ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal) { if (principal.Identity?.Name is not { } name || principal.HasClaim(c => c.Type == "department")) return Task.FromResult(principal); // anonymous or the claim is already there: do nothing
var clone = principal.Clone(); // don't modify the original principal clone.AddIdentity(new ClaimsIdentity([new Claim("department", directory.GetDepartment(name))])); return Task.FromResult(clone); }}- How does
ClaimsIdentitydiffer fromClaimsPrincipal? — An identity is the claims of one authentication; a principal is the user with one or more identities. - Why is
IsAuthenticatedfalseeven though there are claims? — The identity has noAuthenticationType. - How many times does
IClaimsTransformationrun? — On everyAuthenticateAsync, sometimes several times per request; the result isn’t saved to the cookie. - How do you refresh claims without a new sign-in? — Reissue the cookie with
SignInAsyncor reissue the token; in ASP.NET Core Identity the security stamp validator does it. - How is a claim different from a permission? — A claim says who the subject is, a permission says what it may do; the app decides by matching claims against rules.
How does policy-based authorization work?
Section titled “How does policy-based authorization work?”What it is
A policy is an access rule with a name. Inside are requirements: “signed in”, “has a role”, “has a claim” or a custom condition.
How to use
- Register the policy:
AddAuthorizationBuilder().AddPolicy("HrOnly", p => p.RequireClaim("department", "HR")). - Apply it:
[Authorize(Policy = "HrOnly")]on a controller or.RequireAuthorization("HrOnly")on an endpoint. - For a complex rule, write your own requirement and handler — a class that makes the decision.
Why
The rule is described once. If auditors also need access, you change the policy registration, not every attribute.
Common mistake
Not registering the handler in DI. Then nobody handles the requirement, and the policy always denies.
Takeaway: a policy is a named set of requirements applied by name.
How it works
AddAuthorizationBuilder()(.NET 7+) registers policies; each is a list ofIAuthorizationRequirement.- On a check,
IAuthorizationServicecreates anAuthorizationHandlerContext: the user, the requirements and the resource. - All registered
IAuthorizationHandlerinstances run. A handler callscontext.Succeed(requirement),context.Fail()or nothing. - The policy succeeds if all requirements succeed and nobody called
Fail.
How they combine
| Situation | Result |
|---|---|
| several requirements in a policy | all are needed (AND) |
| several handlers for one requirement | one Succeed is enough (OR) |
at least one Fail() |
denial, even if others called Succeed |
Resource-based authorization
An attribute is checked before the data is loaded. So “only the author can edit” is checked in code: load the document, call AuthorizeAsync(User, document, "EditDocument"), and return Forbid() on failure. The handler derives from AuthorizationHandler<TRequirement, Document>; for CRUD there is the ready-made OperationAuthorizationRequirement.
Pitfalls
- Handlers run in any order and even for an unauthenticated user.
- For an endpoint,
context.Resourceis theHttpContext, not your object. - A typo in a policy name gives an
InvalidOperationExceptionon the first request, not at startup.
Rule of thumb
A simple condition — RequireRole, RequireClaim, RequireAssertion. Need services or the object — a requirement and a handler.
Takeaway: a policy is AND across requirements and OR across handlers, and an object check is an AuthorizeAsync call in code.
Under the hood
AuthorizationMiddlewarecombines the endpoint metadata into one policy, andIPolicyEvaluatorauthenticates the user with the policy’s schemes and callsIAuthorizationService.DefaultAuthorizationHandlerProviderreturns everyIAuthorizationHandlerfrom DI: each handler runs on every check, andAuthorizationHandler<T>simply skips requirements of other types.- The result of an endpoint check goes to
IAuthorizationMiddlewareResultHandler: by default a challenge or a forbid. A custom implementation changes the response, for example 404 instead of 403. IAuthorizationPolicyProviderbuilds policies by name on the fly ("MinAge:18"), and since .NET 8 an attribute implementingIAuthorizationRequirementDatacarries its own requirements without a registered policy.
Cost
Handler results aren’t cached: a database or permission-service call inside a handler runs on every check. A handler can be registered with any lifetime; a scoped one gets the request’s DbContext.
Trade-offs
- Attribute or call in code. An attribute is visible in the metadata, and a test can verify it is there. An
AuthorizeAsynccall is easy to forget in a new method. - Object or list.
AuthorizeAsyncchecks one loaded object. For lists, permissions are expressed in theWHEREclause, otherwise pagination and performance break. - 404 or 403. 404 hides that the resource exists; 403 is clearer for the client and for support.
How to check in production
- Logs in the
Microsoft.AspNetCore.Authorizationcategory show which requirements failed. - Handlers are tested without a web host: create an
AuthorizationHandlerContextwith the right principal and resource and callHandleAsync.
Takeaway: policies are ASP.NET Core’s built-in rules engine: requirements are declarative, decisions live in handlers, an object goes through IAuthorizationService, and lists are filtered in the query.
using System.Security.Claims;using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();builder.Services.AddAuthorizationBuilder() .AddPolicy("EditDocument", p => p.AddRequirements(new EditDocumentRequirement()));// Two handlers for one requirement: either one succeeding is enough (OR)builder.Services.AddSingleton<IAuthorizationHandler, AuthorHandler>();builder.Services.AddSingleton<IAuthorizationHandler, AdminHandler>();
var app = builder.Build();
app.MapPut("/documents/{id:int}", async (int id, ClaimsPrincipal user, IAuthorizationService auth) =>{ var doc = new Document(id, Author: "bob"); // in real life — loaded from the database var result = await auth.AuthorizeAsync(user, doc, "EditDocument"); return result.Succeeded ? Results.Ok(doc) : Results.Forbid(); // neither the author nor Admin — Forbid}).RequireAuthorization();
app.Run();
public record Document(int Id, string Author);
public class EditDocumentRequirement : IAuthorizationRequirement { }
// Resource handler: receives the loaded documentpublic class AuthorHandler : AuthorizationHandler<EditDocumentRequirement, Document>{ protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, EditDocumentRequirement requirement, Document doc) { if (context.User.Identity?.Name == doc.Author) context.Succeed(requirement); return Task.CompletedTask; }}
public class AdminHandler : AuthorizationHandler<EditDocumentRequirement>{ protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, EditDocumentRequirement requirement) { if (context.User.IsInRole("Admin")) context.Succeed(requirement); return Task.CompletedTask; }}- How do you express “OR” between two conditions? — Several handlers for one requirement, or
RequireAssertionwith||. - Why call
context.Fail()when you could just not callSucceed? —Failguarantees a denial even if another handler calledSucceed, for example for a blocked user. - Why can’t “document author” be checked with an attribute? — The attribute runs before the document is loaded; the resource is passed to
AuthorizeAsyncexplicitly. - How do you return 404 instead of 403 when the middleware denies access? — A custom
IAuthorizationMiddlewareResultHandler. - How do you get a policy from a name pattern such as
MinAge:18? — A customIAuthorizationPolicyProvideror an attribute implementingIAuthorizationRequirementData(.NET 8+).
When is RBAC not enough: ABAC, ReBAC and external policy engines?
Section titled “When is RBAC not enough: ABAC, ReBAC and external policy engines?”What it is
- Role explosion — every combination of title, department and region gets a role:
HR_Kyiv_Manager,HR_Lviv_Managerand so on, hundreds of them. - ABAC (attribute-based) — a rule over attributes decides: of the user (department, title), the object (department, classification), the action and the context (time, network).
- ReBAC (relationship-based) — relationships decide: Anna is an editor of a folder, the folder contains a document, so Anna can edit it.
Example
| Rule | Model |
|---|---|
| the admin area is for administrators only | RBAC |
| a manager sees requests from their own department | ABAC |
| a document is visible to those it was shared with | ReBAC |
Common mistake
Solving every new rule with a new role. A year later there are more roles than users, and nobody remembers what each one grants.
Takeaway: roles are for coarse boundaries; attributes and relationships are for “my own” and “shared with me” rules.
How it works
- ABAC. Per NIST SP 800-162, attributes of the subject, object, operation and environment are evaluated against a policy. In ASP.NET Core this is a handler that compares the user’s
departmentclaim with the request’s department. - ReBAC. Permissions are derived from a graph. Zanzibar stores relationships as
object#relation@usertuples, such asdoc:readme#owner@10, and the configuration defines derivation: “a folder’s viewer is also a viewer of its documents”. - OPA (Open Policy Agent) — a general-purpose engine: the app sends JSON with the request data, and rules written in Rego return a decision.
When to choose what
| Model | Fits when |
|---|---|
| RBAC | permissions follow job titles, there are dozens of roles |
| ABAC | “my own” rules: department, region, classification, time |
| ReBAC | sharing, nested folders and groups, delegation |
Where to check
- Gateway — coarse: is the user signed in and allowed into the service.
- Service — precise: this user and this object.
- Database — lists: a
WHEREcondition or row-level security.
Pitfalls
- Checking one object is easy, showing “everything I can access” is not: filtering after the query breaks pagination.
- User attributes come from claims or the directory, not from request parameters.
Rule of thumb
Start with roles and policies, implement ABAC in handlers, and bring in an external engine when many services share the rules.
Takeaway: RBAC is “who you are”, ABAC is “what attributes you and the object have”, ReBAC is “how you are related to the object”.
Under the hood
- NIST SP 800-162 splits ABAC into points: the PEP enforces the decision, the PDP makes it, the PIP supplies attributes, the PAP stores policies. In ASP.NET Core the middleware and
AuthorizeAsynccalls are the PEP, and handlers are a built-in PDP. - Zanzibar (USENIX ATC 2019) serves Drive, YouTube, Cloud: trillions of ACLs and millions of checks per second. Against the “new enemy problem” — a revoked user sees content added after the revocation — the client stores a version token (zookie) with the content, and the check uses data no older than that. In SpiceDB this is the ZedToken.
Cost
- An external PDP is a network call per check: it runs next to the service, and decisions are cached.
- “What can I access” is a separate, expensive query:
ListObjectsin OpenFGA,LookupResourcesin SpiceDB. - Relationships are duplicated in the app database and in the graph: you need synchronization and reconciliation.
Trade-offs
| Where to check | Pro | Con |
|---|---|---|
| gateway | once for all services | doesn’t know the object’s data |
| service | full object context | rules duplicated across services |
| database (RLS) | protects against a forgotten check in code | logic in SQL, needs user context |
RLS requires passing the user into the connection: set_config in PostgreSQL, sp_set_session_context in SQL Server. With connection pooling the value is set per transaction, otherwise the next request inherits it.
How to check in production
- Every decision is logged: who, which action, on which object, the result, the policy version.
- Policies are tested in CI:
opa testfor Rego,fga model testfor an OpenFGA model.
Takeaway: RBAC doesn’t go away, it gets extended: attributes in handlers, relationships in a graph such as OpenFGA, lists in the query or RLS, and an external PDP pays off when many services share the rules.
using System.Security.Claims;using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();builder.Services.AddSingleton(TimeProvider.System);builder.Services.AddSingleton<IAuthorizationHandler, ApproveRequestHandler>();builder.Services.AddAuthorizationBuilder() .AddPolicy("ApproveRequest", p => p.AddRequirements(new ApproveRequirement()));
var app = builder.Build();
app.MapPost("/requests/{id:int}/approve", async (int id, ClaimsPrincipal user, IAuthorizationService auth) =>{ var request = new AccessRequest(id, Department: "HR", RequestedBy: "bob"); // from the database var result = await auth.AuthorizeAsync(user, request, "ApproveRequest"); return result.Succeeded ? Results.Ok() : Results.Forbid();}).RequireAuthorization();
app.Run();
public record AccessRequest(int Id, string Department, string RequestedBy);
public class ApproveRequirement : IAuthorizationRequirement { }
// ABAC: a decision from subject, object and environment attributespublic class ApproveRequestHandler(TimeProvider time) : AuthorizationHandler<ApproveRequirement, AccessRequest>{ protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, ApproveRequirement requirement, AccessRequest request) { var user = context.User; var hour = time.GetLocalNow().Hour; if (user.IsInRole("Manager") // subject: role && user.HasClaim("department", request.Department) // subject and object: department && user.Identity?.Name != request.RequestedBy // object: not your own request && hour is >= 8 and < 20) // environment: working hours context.Succeed(requirement); return Task.CompletedTask; }}-- The app connects as a non-owner: the table owner bypasses RLSALTER TABLE access_requests ENABLE ROW LEVEL SECURITY;
-- Without app.department_id the condition yields NULL and no rows are visibleCREATE POLICY same_department ON access_requests USING (department_id = current_setting('app.department_id', true)::int);
BEGIN;-- true: the value lasts until the end of the transaction and won't leak to the next pooled requestSELECT set_config('app.department_id', '42', true);SELECT * FROM access_requests; -- only requests from department 42COMMIT;- How do you spot role explosion? — The number of roles approaches the number of users, and roles differ by a single parameter — it’s time to make that parameter an attribute.
- How does ABAC differ from ReBAC? — ABAC compares properties, ReBAC walks relationships in a graph; “owner” and “shared with” are more natural in ReBAC.
- What is the new enemy problem? — A check against stale ACLs shows new content to a revoked user; Zanzibar solves it with a version token (zookie).
- How do you list the objects a user can access in ReBAC? —
ListObjectsin OpenFGA orLookupResourcesin SpiceDB, or a copy of the permissions in the app database for filtering. - What is better and worse about RLS compared with a filter in code? — A forgotten
WHEREcan’t bypass it, but the logic moves into SQL and the connection needs the user context.
How would you design a centralized access management system for many internal applications?
Section titled “How would you design a centralized access management system for many internal applications?”What it is
An access management system is one place that shows every app in the company and who has which permissions in it.
How it works for an employee
- Picks an app and a role in the catalog and writes a justification.
- Their manager and the app owner approve the request.
- The system grants permissions through an AD group or the app’s API.
- Everything goes into a log: who asked, who approved, when it was granted.
Principles
- Least privilege — only what the job needs.
- Expiry — temporary permissions end on their own.
- Review — the manager regularly confirms the permissions are still needed.
Common mistake
Granting but never revoking. An employee moves to another department, the old permissions stay, and after a few moves they can access almost everything — that is privilege creep.
Takeaway: request, approval, grant, log and review — in one place for every app.
How it works
- Catalog — apps, their permissions, roles as bundles of permissions, owners.
- Employee source — the HR system or AD: joiners, movers, leavers.
- Requests — the approval route depends on the permission’s risk: manager, app owner, security team.
- Granting — AD groups, the SCIM protocol or the app’s API; a rerun must not duplicate permissions.
- Delivery to the app — claims at sign-in or a call to the permission service.
- Audit and review — a log of every change and regular confirmation campaigns.
When to choose what
| Permission delivery | Pro | Con |
|---|---|---|
| AD groups or claims in a token | no calls, standard | changes show up after the next sign-in |
| permission API call with a cache | fresh, fine-grained permissions | latency, dependency on the service |
Pitfalls
- “Same as my colleague”. Copying a colleague’s permissions copies the extras too.
- A move is riskier than a departure. When someone leaves, the account is disabled; when someone moves, old permissions quietly pile up.
- Permissions granted around the system. The system can’t see what was granted by hand inside an app — you need reconciliation.
Rule of thumb
Every permission has an owner, a justification and an expiry, and whatever the system granted it can revoke.
Takeaway: catalog, requests, granting, delivery, audit and review — drop any one of them and access sprawls.
Component diagram
- Catalog: apps, permissions, role bundles, owners, risk level.
- Employee sync from the HR system or AD: joiner, mover and leaver events.
- Request engine: risk-based routing and separation-of-duties checks before approval.
- Assignment store — the desired state: who, what, grounds, expiry.
- Provisioning: connectors to AD, SCIM and APIs through a queue with retries.
- Reconciliation: comparing desired and actual state, finding permissions granted around the system.
- A decision API and “permissions changed” events for app caches.
- An immutable audit log.
- Review campaigns and automatic revocation on expiry.
Trade-offs
- Revocation. With claims in a token it arrives only after the token or session lifetime. Teams shorten token lifetimes or check a permission version when the session is refreshed.
- Online check. An in-app cache with a TTL of minutes plus an event that clears it. If the permission service is down, changes are denied (fail closed), and for reads the choice between a stale cache and a denial is agreed with security.
- Separation of duties. Conflicting permission pairs (“creates a payment” and “approves a payment”) are checked at request time; without owners these rules go stale quickly.
How to check in production
- Time from approval to the actual grant, and the number of permissions without an owner.
- Reconciliation mismatches: permissions in apps that the system doesn’t know about.
- The share of permissions confirmed unchanged in reviews: a 100% “approve all” signals rubber-stamping.
- The log answers “who had access to X on date Y”; if it can’t, the audit trail is incomplete.
Takeaway: centralized access is a desired state plus loops that converge reality to it; the main architectural decision is how, and with what delay, permissions reach the apps.
using Microsoft.AspNetCore.Authorization;using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();builder.Services.AddMemoryCache();builder.Services.AddSingleton<PermissionClient>();builder.Services.AddSingleton<IAuthorizationHandler, PermissionHandler>();builder.Services.AddAuthorizationBuilder() .AddPolicy("requests.approve", p => p.AddRequirements(new PermissionRequirement("requests.approve")));
var app = builder.Build();
app.MapPost("/requests/{id:int}/approve", (int id) => $"Request {id} approved") .RequireAuthorization("requests.approve");
app.Run();
public record PermissionRequirement(string Permission) : IAuthorizationRequirement;
public class PermissionClient{ // In real life — an HttpClient to the permission service: GET /users/{user}/permissions?app=hr-portal public Task<string[]> GetAsync(string user) => Task.FromResult<string[]>(["requests.approve"]);}
public class PermissionHandler(PermissionClient client, IMemoryCache cache) : AuthorizationHandler<PermissionRequirement>{ protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionRequirement requirement) { if (context.User.Identity?.Name is not { } user) return;
// One-minute cache: a trade-off between load on the permission service and revocation speed var permissions = await cache.GetOrCreateAsync($"perm:{user}", entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); return client.GetAsync(user); }); if (permissions?.Contains(requirement.Permission) == true) context.Succeed(requirement); }}
// Subscriber to the "permissions changed" event: revocation arrives faster than the TTLpublic class PermissionsChangedHandler(IMemoryCache cache){ public void Handle(string user) => cache.Remove($"perm:{user}");}- How do you revoke access quickly if permissions come in a token? — Short-lived access tokens, refresh token revocation and a permission version check; for cookies, re-validating the principal in
OnValidatePrincipal. - What is separation of duties and where is it checked? — Forbidding conflicting permissions for one person; statically at request time and dynamically when the operation runs.
- How is a catalog role different from an AD group? — A role is a set of permissions with an owner and granting rules; a group is one way to deliver it to an app.
- How do you grant temporary admin access? — A permission with an end date or activation on request for a few hours, as in Microsoft Entra PIM.
- What happens if the permission service is down? — Deny changes, and decide in advance for reads: a short stale cache or a denial.