Back to all posts
·5 min read

Authorization in .NET APIs: from claims to resource-based decisions

When an API endpoint is open to a signed-in user, it can feel as if the first security threshold has been crossed. A verified identity does not mean that a person may see every resource or perform every operation. “Who is this?” and “May this person access this resource for this action?” are different questions. The first is authentication; the second is authorization.

The distinction becomes more important as .NET services grow. A few role checks in a controller can seem sufficient at first. Over time, tenant membership, ownership, record status, subscription plans, approval flows and administrator exceptions arrive. When rules are scattered across endpoints, behaviour becomes inconsistent and a new endpoint can omit the same protection. The goal is not only to deny access, but to make the rule behind a decision explicit, reusable and testable.

Separate authentication from authorization results

A missing valid token returns 401 Unauthorized; an authenticated caller lacking permission returns 403 Forbidden.

The authentication layer validates token signature, lifetime, audience and issuer, then produces a trustworthy ClaimsPrincipal. The authorization layer uses that identity and application context to evaluate permission. Acting directly because a token contains a role can be inadequate, especially in systems where membership or account state can change.

Critical operations may need current membership or state checks, not only token lifetime.

Turn role checks into business rules

[Authorize(Roles = "Admin")] is simple and appropriate in some cases. Rules such as “may export a report”, “may approve another person’s draft” or “may change this tenant’s billing settings” are not only role names. They include an action, resource and context.

Policy-based authorization in .NET helps centralise the rule as a named requirement. A policy such as BillingSettingsWrite or ReportExport can combine relevant claims, roles or custom requirements. An endpoint then states only its intent:

[Authorize(Policy = "ReportExport")]
[HttpPost("reports/{id}/export")]
public async Task<IActionResult> Export(string id) { /* ... */ }

This makes a policy easier to apply, test and document consistently. Name policies for product capability, such as InvoiceApprove.

Decide against the resource after loading it safely

A person allowed to export reports may not be allowed to export every report. A report can belong to another tenant, be locked or be visible only to its owner. This is where resource-based authorization is needed.

A sound flow loads the resource securely with the caller’s tenant context, then passes user, resource and requirement to an authorization handler. The handler evaluates ownership, membership, resource state or a business rule. Choose a consistent public response for not-found versus denied.

The important point is not loading a resource through an unrestricted query and filtering in a controller afterward. The data layer applies tenant and access boundaries by default. Authorization is not one check after a bad query; it is a defence that begins with query design.

Do not treat claims as a real-time database

Claims are useful for relatively stable identity information: user identity, verified-email status, identity provider or broad role. Filling a token with many dynamic permissions, resource lists or large tenant membership data can both enlarge it and make its data stale quickly.

Questions that change with a resource, such as “is this person an editor of this project?”, should be answered from application data when needed. It is safer to centralise that check behind an authorization handler or application service than to write ad hoc SQL in every endpoint. A short-lived, carefully keyed cache can help when needed, but its invalidation after permission change must be planned from the start.

Protect every server-side entry point

Hiding a UI button helps experience but is not a security boundary. A client can create a request by hand, an older mobile release can call a different endpoint, or a background job can begin an operation. HTTP endpoints, GraphQL resolvers, message consumers, background workers and administration commands must each validate their own authorization context.

The assumption that a service call is safe because it comes from an internal network is insufficient too. Calling-service identity, audience, minimum scope and whether the call acts for a user or system should be explicit. If user context is propagated, the system must validate which claims are trusted and who signed them.

For privileged administration, consider strong authentication, narrow scope, time limits and audit records. An administrator role should not be a universal key that silently crosses all data boundaries.

Keep decisions auditable but private

To diagnose an authorization failure, log which policy was evaluated, for what resource type and with what result. Do not log access tokens, a complete claim dump, personal content or sensitive resource details. A request or correlation identifier is often sufficient for support to trace an incident.

An audit record answers a longer-lived question: who attempted which action on which resource, when, and what was the decision? Recording every read can be costly or unnecessary for privacy. Prioritising high-risk writes, exports, role changes and administrative actions is more balanced.

Test authorization through negative scenarios

A test where an allowed user succeeds does not prove the security rule. For every critical feature, test people without identity, with the wrong role, from another tenant, without resource ownership and with expired context. The 401/403 distinction, resource decision, background job and administrative exception should all be covered.

Unit test policy handlers and use integration tests for the combination of endpoint and data access. When a new endpoint is added, an authorization requirement should belong on the code-review checklist.

Good authorization is not writing more if statements in every controller. Systems that verify identity reliably, express product capabilities through named policies, and evaluate resource and data boundaries together grant less privilege while adapting more confidently to changing product rules.