Modern .NET Development

Authentication and Authorisation in Modern ASP.NET Core

The difficult part of identity is not validating a token. It is keeping protocol boundaries, permissions, tenants and operational failure modes clear as the system and organisation change.

ASP.NET Core identity and access flow

Identity failures usually begin with a category error

ASP.NET Core makes authentication handlers and authorisation policies straightforward to configure. That does not make identity architecture straightforward. The expensive mistakes happen when teams ask one mechanism to answer a question it was not designed to answer: using OAuth to prove who signed in, treating a claim as a permission, or assuming a valid access token means its holder may act on every resource in the API.

A useful design keeps four decisions separate. Authentication establishes the caller's identity. Delegation records that one party may call another service with bounded authority. Authorisation decides whether the caller may perform a particular action on a particular resource. Session management controls how long that access remains usable and how it ends. Tokens, cookies, roles and claims carry information between those decisions; none replaces the decisions themselves.

The questions change at different speeds. A person may remain authenticated while moving department, losing approval authority or leaving a tenant. A service may possess a correctly signed token after an incident requires its access to end. Good identity systems expect those changes instead of encoding today's organisation chart into long-lived credentials.

OAuth is delegation; OpenID Connect supplies authentication

OAuth 2.0 answers a delegation question: may this client obtain an access token for a protected resource, with this scope and under this grant? It deliberately does not define a standard user identity. An access token is intended for the resource server, and its contents may be opaque to the client. Inferring a signed-in user from it couples the application to an issuer-specific format and can confuse the token's subject with the human operating the client.

OpenID Connect exists on top of OAuth 2.0 to add an authentication ceremony. It defines the ID token, the openid scope, identity claims, discovery and rules for validating the authentication response. The ID token tells the client about the authentication event; the access token authorises calls to an API. Sending an ID token to an API because it happens to be a JWT is a protocol error.

For an interactive server-rendered ASP.NET Core application, a common boundary is OpenID Connect for the challenge and callback, followed by a local encrypted authentication cookie. For a browser application calling APIs, use the authorisation code flow with PKCE. PKCE binds the token exchange to the client that began the flow and reduces the value of an intercepted authorisation code.

The client credentials grant is for a confidential machine identity acting as itself. A single-page application cannot keep a client secret: every secret shipped to a browser is available to its user and to injected script. Giving an SPA client credentials turns a shared public credential into an impersonation route. If a browser needs confidential-client behaviour, put it behind a server-side backend-for-frontend that protects credentials and issues a secure session cookie.

Secure sign-in and API access across applications
Authentication, delegation and application authorisation are separate trust decisions, even when one sign-in journey crosses all three.

Cookies are often the safer browser default

JWTs are useful when an API must validate bearer credentials without consulting the issuer on every request, especially across independently deployed services. That property is frequently mistaken for a reason to use them everywhere. A JWT is a token format, not a session architecture, and self-contained validation transfers complexity into expiry, revocation, key rotation and claim freshness.

For a conventional web application, a cookie-backed server session is often the better choice. The browser sends an HttpOnly cookie automatically, JavaScript does not need access to bearer tokens, and the server can invalidate or refresh session state centrally. Secure and an appropriate SameSite setting matter, as does antiforgery protection for state-changing requests. Storing bearer tokens in browser storage trades these understood controls for a larger token-theft surface under cross-site scripting.

Follow the trust boundary. Use cookies where browser and server form one application boundary. Use access tokens where a separately secured client delegates calls to an API. In a backend-for-frontend design the browser can use a cookie while the backend manages downstream access and refresh tokens, keeping tokens out of browser JavaScript without pretending the APIs do not use OAuth.

Claims are evidence, not permissions

A claim is an assertion made by an issuer: a subject identifier, tenant membership, authentication method, department or delegated scope. Its presence does not automatically make it trustworthy or make it a permission. The application must decide whether it trusts that issuer to make that assertion, whether the claim is current enough, and how it affects this resource and action.

The loose use of permission and privilege causes trouble here. A permission is a named capability the policy system can evaluate, such as invoice.approve. A privilege is the effective power a caller gains, which may be broader because of resource ownership, data reach or combinations of permissions. An export permission scoped to one account is not equivalent to the same permission over every tenant. Least privilege limits that effective power, not merely the number of strings in a token.

Scopes have another meaning. They bound delegated access granted to an OAuth client and are normally coarse API capabilities. A scope such as orders.write can be necessary without being sufficient: the API must still decide whether this subject may change this order in this tenant and state. Treating scopes as the whole business permission model leaks an external protocol concern into domain authorisation.

Use stable identifiers in claims, not display names or mutable business labels. A claim such as department=West Midlands Sales becomes stale when teams merge or names change. Stable subject and tenant identifiers let the application resolve current policy from an authoritative store when freshness matters. Avoid embedding a user's whole access matrix; large credentials are slow, leak organisational detail and remain wrong until they expire.

Why Admin roles become unmaintainable

Role-based access control works when roles are few, stable and genuinely match job functions. It breaks down when each exception creates another role: Admin, Regional Admin, Read-only Regional Admin, Temporary Finance Admin. This role explosion shows that the model is combining capabilities, resource scope and contextual rules in one label.

An Admin role is especially dangerous because nobody can say what it grants without searching the codebase. New features often interpret it as universal access, silently increasing the power of existing accounts. It also defeats separation of duties: the person who manages users need not be the person who approves refunds or reads payroll.

A durable model gives domain permissions stable names, groups them into assignable roles for convenience, and evaluates resource constraints separately. Organisational roles may change while permissions such as supplier.edit and payment.release remain meaningful. Store role-to-permission assignments in data or configuration rather than scattering role-name checks through controllers. Audit changes to assignments and policy definitions.

Attribute-based access control becomes attractive when the answer depends on relationships and context: tenant, resource owner, transaction value, record state, region, authentication strength or time-limited delegation. ABAC is not automatically superior. Its rules are harder to explain, cache and audit, so use it for dimensions that are truly dynamic. A pragmatic system often uses RBAC to establish candidate capabilities and resource-based or attribute-based policies to narrow them.

Model ASP.NET Core policies around business actions

Attributes such as [Authorize(Roles = "Admin")] are convenient but couple endpoints to an organisational label. Prefer policies named after durable business actions. A custom requirement and handler can combine the caller's candidate permission with current resource data, keeping the decision explicit and testable.

public sealed record ApproveInvoiceRequirement()
    : IAuthorizationRequirement;

public sealed class ApproveInvoiceHandler
    : AuthorizationHandler<ApproveInvoiceRequirement, Invoice>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ApproveInvoiceRequirement requirement,
        Invoice invoice)
    {
        var tenantId = context.User.FindFirst("tenant_id")?.Value;
        var permissions = context.User.FindAll("permission")
            .Select(claim => claim.Value);

        if (tenantId == invoice.TenantId &&
            permissions.Contains("invoice.approve") &&
            invoice.Status == InvoiceStatus.AwaitingApproval)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

The example is only one layer. A production policy may resolve approval limits, delegation or separation-of-duties rules from an authoritative service. Load the resource first, authorise against that exact resource, and only then mutate it. Never accept a tenant or owner supplied by the request as proof of access.

Centralised handlers make policy tests possible: wrong tenant, stale membership, prohibited state, absent permission and valid delegation should each have a case. They also create one place to emit a structured decision containing the policy, subject, resource, tenant and outcome. Avoid logging tokens or sensitive claim values. Operators need to understand a denial without acquiring credentials from the logs.

Multi-tenant claims must be scoped, not merely present

A tenant_id claim is useful only if every tenant-owned resource is checked against it. Validating the claim at sign-in and then trusting route or body identifiers creates an insecure direct object reference across tenants. Apply tenant filtering at the data boundary as defence in depth, but retain explicit resource authorisation: a global query filter can be bypassed by administrative paths, raw SQL or a future data access change.

Users who belong to several tenants need an unambiguous active-tenant model. Issuing a token or session for one selected tenant keeps decisions and audit records clear. Packing permissions for many tenants into one token creates large, complicated claims and makes it easier to evaluate the right permission against the wrong tenant. If switching tenant changes authority, treat the switch as a new security context and re-evaluate access.

Machine identities need the same discipline. A background integration should receive only the tenants and actions it requires, not a platform-wide service role because workload access is inconvenient to model. Record both the calling workload and any human on whose behalf it acts; collapsing them into one subject loses accountability.

Revocation, clock skew and key rotation are one operational problem

A signed access token remains valid until expiry unless the resource server performs an additional current-state check. Deleting a session at the identity provider does not recall JWTs already issued. Short access-token lifetimes reduce the exposure window, but refresh tokens become high-value credentials requiring rotation, replay detection and secure storage. Introspection or a denial list can provide faster revocation at the cost of network dependency, latency and cache consistency.

Choose the mechanism from the required revocation time. If access must end within seconds after an employee is suspended, a fifteen-minute self-contained token cannot meet the requirement alone. High-risk actions can use current membership or security-version checks even when ordinary reads accept the token until expiry. This hybrid spends coordination cost where stale authority would do the most damage.

Distributed machines never agree perfectly, so validators allow clock skew around nbf and exp. Excessive tolerance silently extends token lifetime; zero tolerance produces intermittent failures when issuer and API clocks differ or a token is used immediately. Keep hosts synchronised, set an explicit small tolerance appropriate to the environment, and log the validation reason and server time without logging the token. Failures clustered at issuance or expiry usually point to clocks before cryptography.

Key rotation must overlap safely. Resource servers need the issuer's new public key before tokens signed with it arrive, while retaining old keys until their tokens expire. Cache discovery metadata, but refresh it when an unknown key identifier appears and ensure an issuer outage does not immediately break validation of known keys. Rehearse rotation; discovering that a long cache or blocked route prevents key refresh during an incident is too late.

A production review should test the negative space

Happy-path sign-in proves little. Before release, answer these questions with tests or operational evidence:

  • Can a token issued for another audience, issuer or tenant reach an endpoint?
  • Does every resource decision fail closed when a claim or policy dependency is missing?
  • How quickly does access end after account suspension, permission removal or tenant exit?
  • Can browser script read a bearer or refresh token, and what happens after an XSS defect?
  • Can each workload credential be traced, scoped and rotated independently?
  • What happens during signing-key rollover, identity-provider outage and clock drift?
  • Can support explain a denial without viewing secrets or granting a broader role?

Distinguish an unauthenticated request from an authenticated caller that lacks authority. Return a challenge when authentication is required; return a forbidden result when the established identity is insufficient. Clients, telemetry and support teams behave badly when every failure is flattened into the same response.

A durable starting point

Begin with actors, trust boundaries and required revocation time, not a preference for cookies or JWTs. Use OpenID Connect for user authentication, OAuth for delegated API access and client credentials only for confidential workloads acting as themselves. Keep browser tokens out of JavaScript when a cookie-backed server boundary can do the job.

Inside the application, name permissions after stable business actions and treat claims as policy inputs, not the policy itself. Combine simple roles with resource and attribute checks where the domain requires them. Scope every decision to the active tenant, test failures, and make key rotation, time synchronisation and access termination part of the design. ASP.NET Core provides the mechanisms; the engineering work is deciding exactly what each mechanism is allowed to mean.

Related C3 Software guides

Frequently asked questions

Should an ASP.NET Core web application use cookies or JWTs?

Use a secure cookie for a conventional browser-to-server application unless a separate API trust boundary requires bearer tokens. A backend-for-frontend can keep OAuth tokens server-side while the browser uses an HttpOnly cookie.

Are claims the same as permissions?

No. Claims are assertions from an issuer. An ASP.NET Core policy decides how trusted claims, current application data and resource context combine to permit or deny an action.

When should a system move from RBAC to ABAC?

Add attribute or resource-based rules when access depends on ownership, tenant, value, state, region or delegation and role exceptions are multiplying. Retain roles where they still provide a clear, auditable grouping of stable permissions.

Can a JWT be revoked immediately?

Not through signature validation alone. Immediate revocation requires a current-state mechanism such as introspection, a denial list, a security-version check or a gateway session. Otherwise the token remains usable until it expires.

Review your identity architecture with C3 Software

C3 Software Limited has built and supported business software in the UK since 2001. If roles, claims or tenant boundaries are becoming difficult to reason about, talk to C3 Software about an architecture review or practical implementation plan.