Skip to content

Instantly share code, notes, and snippets.

@joperezr
Last active April 1, 2025 21:34
Show Gist options
  • Save joperezr/6f2729aea6d45a77281f8d3cac57bddc to your computer and use it in GitHub Desktop.
Save joperezr/6f2729aea6d45a77281f8d3cac57bddc to your computer and use it in GitHub Desktop.
Authentication & Authorization in ASP.NET Core

Deep Dive into ASP.NET Core Authentication, Authorization, and Identity

Authentication vs. Authorization vs. Identity

Authentication is the act of verifying who a user is. When a user successfully authenticates (for example, by entering the correct credentials or token), the application establishes the user’s identity (often represented by a ClaimsPrincipal in ASP.NET Core) (Introduction to authorization in ASP.NET Core | Microsoft Learn). In other words, authentication answers “Who are you?” and results in an identity that the app can use. By contrast, Authorization is about what the authenticated user is allowed to do or access. It answers “What are you allowed to do?” (Introduction to authorization in ASP.NET Core | Microsoft Learn). For example, after logging in (authentication), an admin user might be authorized to access a management page that a regular user cannot. Authorization is distinct from authentication but depends on it – you must know who the user is before deciding what they can do (Introduction to authorization in ASP.NET Core | Microsoft Learn).

Identity in this context can refer to both the user’s identity information and the ASP.NET Core Identity system. The user’s identity encompasses the user’s attributes, credentials, and other identifying claims (like name, email, user ID). ASP.NET Core’s built-in Identity framework is a membership system that provides user registration, login, role management, hashing of passwords, etc. It helps manage the user’s identity data (typically in a database) and integrates with authentication. For instance, ASP.NET Core Identity can handle authenticating a user by verifying their password and then creating the identity (ClaimsPrincipal) used for authorization in the app. In summary: authentication establishes identity, authorization uses identity to enforce permissions, and Identity (the framework) is a tool to manage and persist user identities (accounts, passwords, roles) in ASP.NET Core apps.

OAuth 2.0 and OpenID Connect Fundamentals

Modern authentication often relies on OAuth 2.0 and OpenID Connect (OIDC), which are industry-standard protocols. It’s crucial to understand the roles defined by OAuth 2.0 and how various flows work, as well as how OIDC builds on OAuth2 to add identity information.

OAuth 2.0 Roles: OAuth defines four primary roles in an authorization exchange (OAuth 2.0 and OpenID Connect protocols - Microsoft identity platform | Microsoft Learn) (OAuth 2.0 and OpenID Connect protocols - Microsoft identity platform | Microsoft Learn):

(OAuth 2.0 Roles) Illustration of OAuth 2.0 roles: the Resource Owner (user) grants access to a Client application, which obtains tokens from the Authorization Server (IdP) and uses them to access the Resource Server (API) on the user’s behalf.

OAuth 2.0 vs OpenID Connect: OAuth 2.0 is an authorization framework – it’s primarily about delegated access (allowing a client to act on behalf of a user to access an API). By itself, OAuth 2.0 issues access tokens (and optionally refresh tokens) that don’t inherently convey the user’s identity to the client; they just grant access to resources. OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. OIDC issues an ID Token (in JWT format) that contains information about the user (the “identity claims”), in addition to the OAuth2 access token (OAuth 2.0 and OpenID Connect overview | Okta Developer) (OAuth 2.0 and OpenID Connect overview | Okta Developer). In practical terms, the flows for OAuth2 and OIDC are similar, but an OIDC authentication flow gives the client an ID token so the client can reliably identify the user who logged in. As one source puts it: “The high-level flow looks the same for both OpenID Connect and regular OAuth 2.0 flows. The primary difference is that an OpenID Connect flow results in an ID token, in addition to any access or refresh tokens.” (OAuth 2.0 and OpenID Connect overview | Okta Developer).

OAuth2 Authorization Flows: OAuth 2.0 defines several flows (grant types) to accommodate different application scenarios. Some common flows and when they’re used in ASP.NET Core applications are:

  • Authorization Code Flow: The most common flow for server-side web applications and now recommended for single-page apps as well (with PKCE). The client (your ASP.NET Core web app or SPA) redirects the user to the authorization server’s login page. After the user authenticates and consents, the authorization server redirects back with an authorization code. The client app then exchanges this code for tokens (access token and an ID token if using OIDC). This flow keeps sensitive tokens off the URL and requires a back-channel exchange (enhanced security). In ASP.NET Core, if you use the OIDC middleware (AddOpenIdConnect), it by default uses the authorization code flow (with PKCE for public clients).
  • Implicit Flow: An older flow where tokens (ID token or access token) are returned directly in the redirect URL. Implicit flow has no back-channel token exchange. It was historically used for pure client-side apps (like SPAs) due to not requiring a client secret. However, implicit flow is no longer recommended because it exposes tokens in the browser address bar and has other security limitations (modern SPAs should use Authorization Code with PKCE instead).
  • Client Credentials Flow: Used for server-to-server or service accounts (no user involved). The client (which is typically an ASP.NET Core service or daemon) authenticates with the authorization server using its own credentials (e.g., a client secret or certificate) and gets an access token. This is useful for background services or APIs that need to call other APIs. In ASP.NET Core, you might use this flow to acquire tokens using a library like MSAL, or you might configure an API to accept client-credentials tokens by validating the client identity in the token.
  • Resource Owner Password Credentials (ROPC): A legacy flow where the user’s credentials (username/password) are collected by the client and exchanged directly for a token. This flow is highly discouraged (and not supported at all by some IdPs) because the client gets to see the user’s password, breaking the purpose of OAuth’s delegation. Instead, interactive users should use the Authorization Code flow.
  • Device Code Flow: Designed for devices with no browser or limited input (e.g., a smart TV). The device asks the user to visit a link on another device to authenticate. Meanwhile, the device polls the auth server for a token. ASP.NET Core might not directly implement this flow, but you could use libraries or the Microsoft Identity client to support it for tools or CLIs.

Roles and Token Types in OIDC: In OIDC terminology, the authorization server is also called the OpenID Provider (OP), and the client is called the Relying Party (RP) (OAuth 2.0 and OpenID Connect overview | Okta Developer). The tokens involved include:

  • ID Token: a JWT that contains user identity claims (like name, email, user ID). It’s meant for the client and is signed by the IdP. In ASP.NET Core, after an OIDC login, the middleware will validate the ID token and use it to sign the user into the application (often creating a session cookie with the user’s claims).
  • Access Token: usually a JWT or opaque token that authorizes access to APIs (the resource server). The client will include this token in requests to the API (typically in the Authorization: Bearer <token> header). The API (resource server) validates the token to check the client’s permissions (scopes, roles, etc.).
  • Refresh Token: a long-lived token that the client can use to get new access tokens after the current one expires, without user interaction. Refresh tokens should be stored securely (usually only on trusted backend clients or in secure browser storage if used in SPAs) since they can be used to obtain new access tokens “forever” until revoked.

Mapping OAuth2/OIDC to ASP.NET Core: In practical ASP.NET Core development, your app can play different roles:

  • If you are building an ASP.NET Core server-side web app that allows users to sign in via an external IdP (like Azure AD, Google, Okta, etc.), then your app is the OAuth Client (OIDC Relying Party). You would use the OpenID Connect authentication handler (via AddOpenIdConnect) along with Cookie authentication. The OIDC middleware handles redirecting to the IdP, receiving the authorization code, and redeeming it for tokens. It will then create an application login (usually a cookie) for the user containing the identity (claims from the ID token). In this scenario, the external IdP is the Authorization Server/OP. Your app might not itself issue access tokens, but it may receive an access token from the IdP if it needs to call APIs on the user’s behalf (for example, if your web app calls a downstream API like Microsoft Graph, the OIDC middleware can be configured to obtain an access token for that API).
  • If you are building an ASP.NET Core Web API that needs to be secured, your API is a Resource Server. You wouldn’t use the OIDC handler here. Instead, you’d configure JWT Bearer authentication (AddJwtBearer) so that the API can accept and validate access tokens issued by an IdP or security token service. You’ll set options like Authority (the issuer’s URL) and the expected audience or scope. The API then uses [Authorize] attributes to enforce that only requests with a valid token are allowed. The heavy lifting of token validation (signatures, expiration, issuer) is done by the JWT middleware. For example, if using Azure AD (Entra ID) to secure the API, the access tokens are JWTs that the API will validate using Azure’s public keys and the configured issuer.
  • In more advanced scenarios, you might build your own authorization server in ASP.NET Core – for example, using Duende IdentityServer or another library to issue tokens to other clients. In that case, your ASP.NET Core app itself acts as the IdP/Authorization Server, implementing OAuth2 and OIDC endpoints. (Microsoft’s templates historically included IdentityServer for SPAs, and in .NET 8, new Identity API endpoints allow issuing JWTs for authentication – more on that later.) But if your goal is to integrate with existing identity providers like Azure AD, Auth0, etc., your app usually won’t function as an OAuth server but rather as the client or resource.

In summary, OAuth2/OIDC provide a framework for delegated auth that ASP.NET Core apps participate in either by outsourcing authentication to an external IdP (and consuming tokens), or by accepting tokens in APIs. These protocols enable scenarios like single sign-on, calling APIs on behalf of users, and decoupling authentication from your application logic.

Token Types and Formats (JWTs, Reference Tokens, and Cookies)

Modern authentication uses tokens to carry authentication information. In ASP.NET Core, you will encounter different token formats: JWTs (JSON Web Tokens), reference (opaque) tokens, and the traditional cookie tokens used for web session authentication. Understanding each is key to designing auth in your app.

  • JWT (JSON Web Token): A JWT is a self-contained, stateless token format that is JSON data (claims) signed (and optionally encrypted) into a compact string. A JWT access token contains claims about the user or client and an expiration time, and is digitally signed by the issuer (using an asymmetric key or secret). Because it’s self-contained, any resource server that has the issuer’s public key can validate the token and read the claims without a database lookup. This makes JWTs very convenient for distributed systems and microservices – you don’t need a central session store; the token itself proves the identity and permissions. ASP.NET Core’s JWT Bearer authentication middleware will parse and validate a JWT (verifying the signature and claims) on each request (OAuth reference token - Auth0 Community) (OAuth reference token - Auth0 Community). One downside of JWTs being self-contained is revocation: if you need to revoke a JWT early (before it expires), it’s hard to do because the token is valid until its expiry unless you maintain a revocation list. As the IdentityServer documentation notes, “Once an API has learned about the key material, it can validate self-contained tokens without needing to communicate with the issuer. This makes JWTs hard to revoke. They will stay valid until they expire.” (OAuth reference token - Auth0 Community). Best practices to mitigate this include using short lifetimes for JWTs and using refresh tokens for longevity. In ASP.NET Core, JWTs are commonly used as bearer tokens for API authentication (e.g., an SPA or mobile app passes the JWT in the Authorization header). They can also be used as the format for id_tokens in OIDC, and even for authentication cookies in certain cases (though typically cookies use a different format as described below).

  • Reference (Opaque) Tokens: A reference token (also called an opaque token) is essentially a token ID rather than a token containing data. When an authorization server issues a reference token, it stores the token’s data (claims, expiry, etc.) in a database on the server side, and gives the client a random opaque string as the token. When the client calls an API with this opaque token, the API can’t validate it by itself (since the token is just an identifier); the API must make a back-channel call to the authorization server (for example, an introspection endpoint defined by OAuth2) to look up the token’s details and validity (OAuth reference token - Auth0 Community). This adds an extra step (communication with the auth server on each request or on cache misses), but it allows the server to centrally revoke or update tokens at any time (since the source of truth is the database). IdentityServer (a popular .NET OAuth/OIDC server) supports reference tokens and uses the OAuth2 token introspection standard for this purpose (OAuth reference token - Auth0 Community). The Microsoft Entra ID (Azure AD) and most major IdPs default to JWTs for access tokens, but some enterprise systems or older protocols use opaque tokens. In ASP.NET Core, there isn’t a built-in “AddReferenceToken” middleware, but you can handle opaque tokens by calling the introspection endpoint in a custom auth handler or by using IdentityModel libraries (for example, AddOAuth2Introspection from IdentityModel can be used to integrate with IdentityServer’s reference tokens). In summary, a JWT is a self-contained token (the token itself carries info) whereas a reference token is a pointer to data on the server. “When using reference tokens – IdentityServer will store the contents of the token in a data store and will only issue a unique identifier for this token back to the client. The API receiving this reference token must then open a back-channel communication to IdentityServer to validate the token.” (OAuth reference token - Auth0 Community).

  • Cookie Tokens (Authentication Cookies): In traditional web apps, after a user authenticates, the server creates an authentication cookie that is sent to the user’s browser and then included with each subsequent request. The cookie serves as the token that represents the user’s authenticated session. In ASP.NET Core, the cookie typically contains a serialized, encrypted Authentication Ticket – which includes the user’s identity (claims principal) and some metadata (issue time, etc.). This is handled by the Cookie Authentication Handler (AddCookie). The cookie is usually encrypted and signed using the ASP.NET Core Data Protection system (often referred to as the “machine key” in earlier frameworks). This means the cookie is effectively an opaque blob to the client but the server can decrypt/verify it to retrieve the user’s claims (How does ASP.NET Core know that cookie is valid in cookie authentication? - Stack Overflow) (How does ASP.NET Core know that cookie is valid in cookie authentication? - Stack Overflow). By default, cookie authentication in ASP.NET Core is stateless on the server (aside from key management): the server doesn’t need to store session data because the cookie itself carries the claims (hence, it’s somewhat analogous to a JWT but used in a browser cookie). A Stack Overflow explanation summarizes it: the cookie “contains the full serialized, encrypted ClaimsPrincipal and AuthenticationProperties” (How does ASP.NET Core know that cookie is valid in cookie authentication? - Stack Overflow). Only the server (or servers in a cluster, if they share the Data Protection keys) can read or validate it (How does ASP.NET Core know that cookie is valid in cookie authentication? - Stack Overflow). This makes cookie auth highly efficient for web apps – no database lookup on each request – and secure (if cookies are configured with Secure and HttpOnly flags). However, unlike JWTs, cookies are automatically attached by the browser to requests to the same domain, and they are subject to CSRF concerns if not protected (which ASP.NET mitigates with anti-forgery tokens and SameSite cookie settings). Also, cookies are domain-specific; they don’t easily work across APIs on different domains without forwarding or using the same top-level domain.

    Cookie size considerations: Because a cookie stores data on the client, very large identities (with many claims or roles) can bloat the cookie. If the size grows too large, you might hit browser limits. In those cases, one can switch the cookie handler to “reference mode” where the cookie contains just a session ID and the server keeps the actual claims in a server-side cache or store (How does ASP.NET Core know that cookie is valid in cookie authentication? - Stack Overflow). This is analogous to a reference token approach but for cookies (this is not commonly needed for most apps unless you have dozens of roles/claims per user).

    Cookies vs JWT for web apps: A common question is whether to use cookies or JWTs for a web app. In ASP.NET Core server-rendered apps, the built-in approach is cookies. If you are building an API and a JavaScript front-end, JWTs are more common. Each has pros/cons: cookies are convenient (automatic, http-only, etc.) but tied to the same origin; JWTs are stateless and more flexible for cross-domain, but you must handle storing them securely on the client. We’ll compare these approaches more in the architecture section.

  • Other Token Types: While JWTs are the prevalent self-contained token, there are other formats like SAML tokens (XML-based, used in some enterprise SSO scenarios) or custom proprietary tokens. In ASP.NET Core, JWTs and cookies are the primary formats you’ll deal with. ASP.NET Core Identity in .NET 8 also introduced a new built-in token format for its API authentication mode (not JWT, a custom encrypted token format) (.NET 8: What's New for Authentication and Authorization), but under the hood these behave similarly to reference tokens (the server validates them via its own key material). The key takeaway is understanding whether a token is self-contained or not, and how it’s validated.

Identity Providers (IdPs) and External Authentication

An Identity Provider (IdP) is a service that authenticates users and provides identity information (usually via tokens) to applications. In ASP.NET Core, you can integrate a variety of external IdPs to offload authentication. Here are some common IdPs and how they fit into the picture:

  • Microsoft Entra ID (formerly Azure Active Directory): This is Microsoft’s cloud identity platform used by organizations for enterprise logins, and by developers to secure apps and APIs. Entra ID supports OAuth2 and OpenID Connect and is often used to enable sign-in with Microsoft work/school accounts or personal Microsoft accounts. In ASP.NET Core, you might use the AddOpenIdConnect handler or the Microsoft.Identity.Web library to configure authentication against Entra ID. For example, you can protect a web app or API by registering it in Azure AD and configuring the ASP.NET Core authentication options with the tenant info and client ID. Azure AD issues JWT tokens for access and id tokens. (Note: Azure AD was renamed to Microsoft Entra ID in 2023 (New name for Azure Active Directory - Microsoft Entra | Microsoft Learn) (New name for Azure Active Directory - Microsoft Entra | Microsoft Learn), but the underlying technology and protocols remain the same). Entra ID can serve as both an authentication provider (signing in users to your app) and an authorization server for issuing tokens to call APIs (like Microsoft Graph or your own APIs).

  • Auth0 and Okta: These are popular commercial Identity-as-a-Service platforms. Auth0 (now part of Okta) and Okta provide a cloud-hosted IdP with support for OIDC, OAuth2, social login integration, user management, etc. Developers often use them to avoid building their own identity management. In ASP.NET Core, integrating with Auth0/Okta typically means using OIDC: you configure the OIDC middleware with the authorization server URL, client ID/secret, and the scopes you need. The app then redirects users to Auth0/Okta for login, and Auth0/Okta sends back tokens. Both Auth0 and Okta issue JWTs by default for access tokens (and an ID token for OIDC). They also have their own SDKs, but you can stick to the standard ASP.NET Core authentication libraries.

  • Keycloak: Keycloak is a robust open-source identity provider (originally by Red Hat/JBoss). It can be self-hosted on your servers. Keycloak supports OIDC and SAML and is often used in self-managed scenarios where a company wants full control over the identity server. If your ASP.NET Core app needs to use Keycloak, you again use OIDC middleware. Keycloak will act as the authorization server/IdP and your app as the client. You just plug in Keycloak’s endpoints (issuer, client id/secret). Keycloak can issue JWT tokens and manage user federations, social logins, etc. It’s a good choice if you want an on-premises IdP and don’t want to roll your own.

  • Social Logins (Google, Facebook, etc.): These providers (Google, Facebook, Microsoft account, Twitter, GitHub, etc.) allow users to sign in with their social media or existing accounts. Under the hood, these also use OAuth2/OIDC (Google fully supports OpenID Connect; Facebook has an OAuth2-based system that returns an access token to fetch user info). ASP.NET Core makes integration easy by providing specific authentication handlers (e.g., AddGoogle, AddFacebook, etc. from the Microsoft.AspNetCore.Authentication.* packages). These are essentially preconfigured OAuth/OIDC clients for those providers. For example, when you use AddGoogle, ASP.NET Core knows Google’s authorization and token endpoint URIs and handles the OAuth flow for you. After the external provider returns an identity (usually as claims like name, email), you typically sign the user into your app with a local cookie. Many apps combine ASP.NET Core Identity with social logins — i.e., the app has its own user database but allows linking a Google account login to a local user account.

  • Custom/Self-Hosted Providers: Instead of using third-party IdPs, some scenarios require a custom identity solution:

    • ASP.NET Core Identity (combined with cookie auth) can itself be seen as a mini-identity provider for your single application. It manages users, passwords, and roles. However, on its own it doesn’t issue OAuth2 tokens for other apps to consume; it’s primarily for traditional login to the same app. In .NET 8, Identity introduced API endpoints that can return tokens, but historically if you wanted your Identity-based app to serve tokens, you’d integrate IdentityServer or another OAuth server on top of it.
    • IdentityServer (Duende): This is a framework that turns your ASP.NET Core application into a full OAuth2/OIDC Provider. IdentityServer was used in earlier ASP.NET Core templates for SPA authentication. If you host IdentityServer (v5+ from Duende), you can tailor the authentication flows and token issuance to your needs (including support for custom grants, federation, etc.). It’s commonly used for self-hosted scenarios in enterprise where using a cloud IdP is not possible. IdentityServer can use ASP.NET Core Identity as its user store, effectively bridging your local identities to OAuth2/OIDC.
    • Other Custom Implementations: While it’s possible to write your own OAuth2/OIDC token issuing logic from scratch, it is not recommended – the specs are complex and security pitfalls are plenty. Instead, use a library like IdentityServer or OpenIdDict if you need a custom IdP. The best practice is to rely on proven components or services for identity.

When integrating an external IdP in ASP.NET Core, the typical pattern is: you register the appropriate authentication handler (AddOpenIdConnect, AddGoogle, etc.) with the client credentials (like client ID/secret obtained from the IdP) and necessary settings (callback URL, scopes). You also register cookie auth for maintaining the local session. The authentication middleware takes care of the redirects, token processing, and sign-in. Once the user is logged in, you have a ClaimsPrincipal that may contain information from the IdP (for example, Google returns name and email claims). You can use these claims for authorization decisions in your app.

It’s also worth noting that many IdPs provide additional features like multifactor authentication, user profile management, password recovery, etc., which you get out-of-the-box by using them. This can significantly reduce the burden of implementing those features yourself.

Application Types and Common Authentication/Authorization Patterns

ASP.NET Core supports various application architectures, and the approach to authentication/authorization can differ for each. Let’s break down common app types and how auth is typically handled in each:

1. Server-Side Web Apps (Razor Pages & MVC)

These are traditional web apps where all logic runs on the server and HTML is rendered on the server (Razor views or pages). For these apps, the dominant pattern is cookie-based authentication with optional external login providers:

  • Local Authentication & Identity: Many server-rendered apps use ASP.NET Core Identity for managing users. A typical scenario: a user registers or logs in through a form on your site (over HTTPS). ASP.NET Core Identity checks the credentials (perhaps comparing a hashed password in the database) and, if valid, signs the user in by creating an auth cookie. This cookie is then sent with each request, so on subsequent page loads the user is recognized. You secure parts of your app using the [Authorize] attribute on controllers or pages, optionally specifying roles or policies. The framework will automatically redirect an unauthenticated user to the login page if they try to access a protected page.
  • External Logins: If using an external IdP (say, Azure AD for an intranet app, or Google for a consumer app), the app will typically still end up using a cookie internally. For example, with OpenID Connect, when the user signs in via Azure AD, your app receives an ID token and perhaps an access token. At that point, the OIDC handler will sign the user in locally (again via a cookie containing the user’s claims). The difference is that the initial authentication was done by Azure AD. From the app’s perspective, after the OIDC callback, it just has an authenticated user principal (with claims from the external provider) and issues its cookie.
  • Session Management: Server-side apps can rely on the cookie’s lifetime or server-side session logic to manage how long a user stays logged in. ASP.NET Core Identity provides features like security stamp validation (to detect if a user’s account info changed and re-authenticate if needed) and easy ways to sign out (invalidate the cookie).
  • Calling APIs: If a server-side app needs to call external APIs on behalf of the user (e.g., call GitHub’s API after GitHub login, or call a company’s REST API after Azure AD login), it will typically have an access token from the external provider. The app might store this token (often encrypted in the authentication cookie or in session) and use it to call the external API from the server side. Care should be taken to refresh or handle expiration of these tokens. The ASP.NET Core OIDC handler can be configured to save tokens (via SaveTokens = true) for this purpose.

Summary for Web Apps: Use cookie auth for the actual session. Use ASP.NET Core Identity if you want to manage user accounts in-app (with hashed passwords, email confirmation, etc.). Or use OIDC/OAuth to outsource auth to an external IdP. Either way, once authenticated, you have a claims principal in HttpContext.User and you enforce authorization with attributes or policy checks. This pattern leverages the fact that the browser will send the cookie with each request, and the server can trust that cookie (since it’s signed/encrypted) to identify the user.

2. Blazor Server Apps

Blazor Server shares a lot in common with traditional server-side apps in terms of security. A Blazor Server application runs .NET code on the server and uses a persistent SignalR connection to communicate with the browser. Authentication in Blazor Server is essentially the same as in an MVC/Razor Pages app:

  • You can use cookie authentication. When the user navigates to the Blazor Server app, the HTTP connection establishing the Blazor Circuit can carry the auth cookie. If the user isn’t authenticated, you can redirect them to a login page (Blazor Server can integrate with Identity or external providers similarly to an MVC app). Once authenticated (cookie set), all Blazor interactions (which happen via SignalR/websocket) operate under that user’s context. The AuthenticationStateProvider in Blazor Server will read the HttpContext.User (so it knows who the user is).
  • You can use [Authorize] attributes on Blazor components (razor components) to restrict access, just like on MVC controllers. The framework will prevent the UI from rendering for unauthorized users and can redirect to login.
  • One important note: Because Blazor Server is executing code on the server, authorization checks on the server are reliable and enforceable. You should still guard any sensitive server-side operations with policies or checks (never trust anything coming from the client). But you don’t have to worry about users manipulating the Blazor UI to bypass auth; if they’re not authorized, the server simply won’t execute the action or will refuse it.
  • Blazor Server apps often use ASP.NET Core Identity for handling user accounts if they require login, since it integrates well (you might scaffold Identity pages for login/logout, or use an external provider and then create a local identity for that user). Alternatively, they can use Azure AD authentication (there are templates for Blazor Server that hook up to Entra ID, for example).
  • In terms of tokens: In Blazor Server, you typically wouldn’t issue JWTs to the browser, because the client isn’t making direct calls to APIs — the server code is doing that. If the Blazor Server app needs to call an API (say a REST API) on behalf of the user, it can use the user’s credentials or tokens (similar to any server-side app). You might use Azure AD’s client libraries to acquire an access token using the user’s session (especially if the user logged in via Azure AD). The server can then use that token to call the API. Essentially, treat Blazor Server as a connected web app.

3. Blazor WebAssembly (WASM) Apps

Blazor WebAssembly apps run entirely in the browser (the .NET code is downloaded and executed in WebAssembly). This scenario is more akin to a Single-Page Application (SPA) from a security perspective. The Blazor WASM app itself is static files; it typically communicates with one or more backend APIs. Key points for Blazor WASM auth:

  • Authentication flows: Because the code runs on the client, you can’t use server-side cookie auth in the same way (unless you adopt a special BFF pattern – more on that later). The common approach is to use OIDC/OAuth2 implicit or code flows directly from the Blazor WASM app to an IdP. For example, you might configure Blazor WASM to use Azure AD or Auth0 using OAuth2 Authorization Code flow with PKCE (which is the secure way for public clients). Blazor WASM has support for this via the Microsoft.Authentication.WebAssembly.Msal package or by using the OIDC client JavaScript under the covers. The user will be redirected to the IdP’s login page, then back to a special redirect page in the Blazor app, and the Blazor app will get the tokens.
  • Token storage: Once the Blazor WASM app obtains tokens (an ID token and access token), it needs to store them to make API calls. This is usually done in memory or browser storage. The Microsoft Blazor WASM auth library (using MSAL) will typically keep tokens in browser localStorage or sessionStorage by default to persist the auth state. Developers need to be aware of XSS risks – anything stored in localStorage is accessible to malicious scripts if your app has an XSS bug. As a best practice, ensure you protect your Blazor app from XSS and consider token lifetime/refresh strategies accordingly. (Alternatively, one can store tokens in browser cookies with HttpOnly, but JavaScript/WASM code wouldn’t be able to directly read those; that pattern leans into the BFF approach).
  • Calling APIs: Blazor WASM will call your backend APIs (or third-party APIs) by attaching the access token in the Authorization: Bearer header. Typically, you configure an HttpClient in the Blazor app that automatically adds the token. The backend API must be configured with JWT Bearer authentication and know to trust the issuer (e.g., your Azure AD tenant or Auth0 domain).
  • Authorization in Blazor WASM UI: Blazor WASM provides an AuthorizeView component and similar features to show/hide UI based on the user’s authentication state or roles/claims. This is purely client-side logic – it can improve user experience (e.g., not showing an “Admin” button to a non-admin user), but it cannot actually secure the data. Any critical checks must happen on the server API. As with any SPA, a user could manipulate the client-side code or calls, so the server must enforce authorization on API endpoints.
  • ASP.NET Core Identity with Blazor WASM: One variant is the Hosted Blazor WASM model where you have three projects: the WASM client, an ASP.NET Core API, and optionally an IdentityServer or Identity backend. For example, Microsoft’s template for Blazor WASM with Identity (pre-.NET 8) would configure IdentityServer on the server to issue JWTs to the Blazor client. The Blazor app would log in via that IdentityServer (which in turn might use ASP.NET Core Identity for user storage), and then use the obtained JWT to call the hosted API. Essentially, the server was both the IdP and the API resource server. In .NET 8, since the IdentityServer is no longer included by default, a similar setup can be achieved using the new Identity API endpoints (which issue tokens) – or by integrating an external IdP. The main idea remains: Blazor WASM acts as an OAuth client obtaining tokens, and uses them to authenticate to APIs.

In summary, Blazor WebAssembly is like any single-page application: use OAuth/OIDC to authenticate the user and get tokens, store those tokens safely, and secure the back-end APIs with JWT validation. The UI can reflect the user’s auth state (e.g., show logged-in user’s name, or only render certain components if User.IsInRole("Admin") via AuthorizeView). But always enforce the real rules on the server side (the API).

4. ASP.NET Core Web APIs (token-secured APIs)

If you are building a Web API (or a set of microservice APIs) with ASP.NET Core, you typically won’t have server-side HTML or cookies at all. Instead, these APIs are meant to be consumed by clients (SPA, mobile app, other servers). The common pattern is token-based security:

  • JWT Bearer Authentication: You configure JWT bearer middleware (usually in Program.cs something like builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => {...})). You set up the options to validate tokens issued by your IdP. For example, if using Azure AD, you might set options.Authority = "https://login.microsoftonline.com/<TenantID>/v2.0" and options.Audience = "<ClientID>" or use options.TokenValidationParameters.ValidAudience = "api://...id" depending on Azure AD setup. The JWT handler will download the signing keys (via OpenID Connect metadata) and automatically validate the token’s signature, issuer, audience, and expiry.
  • Authorize Attributes and Policies: On your controllers or endpoints, you use [Authorize] to require a valid token. You can further require specific scopes or roles by using policy-based authorization. For instance, if you want to ensure that only tokens with the scope “Files.ReadWrite” can access a certain endpoint, you might define a policy: options.AddPolicy("FilesScope", p => p.RequireClaim("scp", "Files.ReadWrite")); and then use [Authorize(Policy = "FilesScope")] on the controller action. Similarly, if the token carries roles (say your JWT has a “roles” claim or an “admin” claim), you can require those. Many JWTs from IdPs put roles in a claim type like “roles” or “role” or even as part of “groups”. You might need to configure the JWT handler’s RoleClaimType to ensure User.IsInRole() works with that. By default, if the token has a claim http://schemas.microsoft.com/ws/2008/06/identity/claims/role, ASP.NET will map that to roles.
  • Stateless nature: Each API call is stateless – the API doesn’t remember anything about past calls. It trusts the token on each request. This means you don’t have server-side sessions, which is good for scalability (each server can independently validate tokens).
  • Protecting sensitive data: For highly sensitive operations, beyond checking scopes/roles, you might implement additional checks (like verifying the user’s organization or other claims). This can all be done in an authorization handler or in the controller logic if needed.
  • Authentication vs API keys: Note that some APIs use API keys or other methods. But when we talk about OAuth2/OIDC tokens, we’re focusing on user or service tokens. If you issue API keys, you might treat them similarly to an opaque token – e.g., check a database for the key. But that’s outside OAuth’s scope (often custom).
  • CORS: If your API is consumed by a web front-end on a different origin (e.g., a React app hosted on https://frontend.com calling an API on https://api.yourapp.com), you must enable CORS in ASP.NET Core for the JWT-protected endpoints, or else the browser will block the requests. CORS doesn’t directly relate to auth, but it’s a necessary configuration to allow the token to be sent via AJAX from a different origin.

Development tip: Secure your API methods early. It’s easy during development to leave [AllowAnonymous] and forget – which would be a big hole. A best practice is to globally require authorization (via .RequireAuthorization() on endpoints or a global filter), and then explicitly allow anonymous only where needed (like a health check or an open endpoint). This way, you don’t accidentally expose an API without auth.

5. Single Page Applications (SPA) with Separate ASP.NET Core Backend (e.g., React + Web API)

This is a very common scenario: you have a JavaScript front-end (React, Angular, Vue, etc.) and a separate ASP.NET Core API. Essentially, this is similar to Blazor WASM + API, but with potentially different tech on front-end. Key aspects:

  • User Authentication: The SPA cannot securely store a client secret, so it will use an OAuth2 flow designed for public clients – typically the Authorization Code flow with PKCE (Proof Key for Code Exchange). This is the modern standard for SPAs. Your SPA will redirect the user to an IdP (say Auth0, Okta, or Azure AD B2C) with PKCE, the user logs in, and the IdP redirects back to a redirect URI in your SPA with an authorization code. The SPA (via code in the browser) exchanges that code for tokens. Many SPAs use libraries like Auth0.js, Okta Auth JS, or MSAL.js to handle this process, which includes the token exchange via XHR/Fetch (since doing the exchange requires sending the PKCE verifier and getting tokens, which usually happens via a silent XHR or a dedicated redirect flow). After this, the SPA has an ID token and access token (and possibly a refresh token if using a secure context or Auth0’s rotating refresh tokens for SPAs).
  • Storage and Security: Once the SPA has the access token, it needs to store it to include in API calls. As mentioned, storing in localStorage is common but poses XSS risk; storing in an HTTP-only cookie is another approach (but then you need to handle CSRF). A growing pattern is the Backend for Frontend (BFF) approach, where the SPA doesn’t directly get the token at all – instead, the token is stored in a secure server-side session (e.g., the ASP.NET Core app acts as a proxy). In a pure SPA approach without BFF, you accept the trade-offs and must harden the app accordingly (CSP, XSS prevention).
  • API calls: The SPA will call the ASP.NET Core Web API by including the access token in the header (Authorization: Bearer ...). The ASP.NET Core API validates it (just like the previous section on APIs). If the token is expired or invalid, the API returns 401, and the SPA should handle that (perhaps triggering a refresh token flow or redirect to login).
  • Cookie vs Token for SPAs: It’s worth contrasting: Some developers attempt to use cookie authentication for SPAs (for example, serve the React app from the ASP.NET Core app and use the same domain cookie). While this can work (the React app would do a form-post login or use an XHR to a login endpoint and get a cookie), it often runs into issues with CORS and third-party cookies if the IdP is separate. A hybrid approach is the BFF model: the SPA talks to its own backend (same origin) which uses cookies, and that backend in turn does token stuff with IdP. This can be very secure (no tokens in the browser at all) and avoids CORS, but it introduces more complexity and network calls. The official docs and many community solutions are increasingly advocating BFF for high-security SPAs. For documentation purposes, know both patterns. The classic pattern is “SPA gets a token and calls API,” which we’ve described. The BFF pattern is “SPA calls its own server (maybe through relatively dumb endpoints), server calls API or IdP as needed and returns data, using a secure cookie for the SPA->BFF leg”.
  • Logout in SPA: Logging out often means both clearing the SPA’s tokens (and any client state) and revoking tokens or ending the session at the IdP. With OIDC, there’s often a logout endpoint you can redirect the user to (e.g., a Microsoft Entra ID logout URL, or Auth0 logout, etc.) which will clear the server-side session at IdP and optionally redirect back. You’d also drop the token from storage or clear the cookie. This ensures the user is fully signed out.

In summary for hybrid SPA+API: Use OAuth/OIDC flows to authenticate the user in the SPA, use JWT bearer on the API, and ensure you handle the tokens carefully. The separation of client and server means things like CORS and CSRF need to be considered. It’s a bit more complex than a server-rendered app, but it provides great flexibility and scalability (your API can serve multiple clients, etc.).

Claims, Scopes, Roles, and Policies in ASP.NET Core

The ASP.NET Core security model is claims-based, meaning an authenticated user is represented by a set of claims. Authorization decisions then use those claims, often in the form of roles, scopes, or custom policies. Let’s clarify these terms and how to configure them:

  • Claims: A claim is simply a piece of information about the user (or entity) – a key-value (with optional metadata like issuer). For example, a claim might be “name = Alice”, “email = [email protected]”, or “EmployeeId = 12345”. In ASP.NET, the ClaimsPrincipal holds a collection of claims (accessible via User.Claims in a controller). Each claim has a type (a string, often a URI or a well-known name) and a value. Claims can come from various sources: if you use ASP.NET Core Identity, by default it issues claims like the user’s unique ID, their username, and their roles. If you authenticate via OIDC, the external IdP might supply claims like given name, surname, email, and perhaps group memberships. Claims are the currency of identity – they allow for a flexible representation beyond just “username”. You could have a claim for almost anything (age, security clearance, preferred language, etc.). In authorization, you might check for the presence or value of certain claims. For example, you could require that a user has an “EmailVerified=true” claim to allow posting in a forum, etc. Think of claims as attributes of the user. (“A claim is a piece of information about the user, which is, in essence, a key-value pair.” (Getting to Know the Identity of .NET 4.5 - CODE Magazine)). In ASP.NET Core, you rarely create the claims principal manually (except in cookie auth during login), but you often consume it.

  • Roles: Roles are a classic way to assign permissions by grouping users (role-based access control). In ASP.NET Core, roles are often used through the Identity system: a user can belong to one or more roles (stored in the Identity DB). Roles in implementation are actually just claims with a special type (typically ClaimTypes.Role or "role"). When a user in Identity has roles, the Identity system will create a claim for each role upon sign-in. So you might see claims like “role = Administrator”, “role = User”. The [Authorize(Roles="Admin")] attribute simply checks if the user has a claim with type role and value “Admin”. However, conceptually roles differ from arbitrary claims because they are intended to represent broad categories of user membership (and often each role implies a set of permissions). One important distinction made in documentation: “While roles are claims, not all claims are roles. Claims are meant to be information about an individual user. Using roles to add claims to a user can confuse the boundary between the user and their individual claims.” (Role-based authorization in ASP.NET Core | Microsoft Learn). This means roles ideally shouldn’t be overloaded to store attributes (that’s what claims are for). Roles describe a set of users (e.g., “Administrators” group), whereas claims describe properties of a single user (c# - Should it be a claim, a role or a policy? - Software Engineering Stack Exchange). You might use roles like “Admin”, “Manager”, “Subscriber”, etc., to gate certain actions. Roles are easy to check and manage if the number of roles is relatively small and static. ASP.NET Core Identity has a RoleManager to create roles and assign users to roles.

    • Roles in tokens: If using external IdPs, not all will use the same concept of roles. Azure AD has “groups” or application roles that can be emitted in tokens, which you can map to roles in your app. Okta/Auth0 might also include roles or you might treat certain claims as roles. The ASP.NET JWT bearer auth can be told what claim type to treat as a role (via TokenValidationParameters.RoleClaimType). So you might map, say, an Azure AD “groups” claim GUID to a role name in your app, but that might require a lookup unless the token contains human-readable roles.
    • When to use roles vs claims: If your authorization logic is mostly about grouping users (like “only users in the Admin role can do X”), roles are convenient. If it’s more dynamic or attribute-based (like “only users with Department = Finance and Clearance = High can do X”), claims (and policy) are more straightforward. Roles become unwieldy if you have too many distinct permissions – that’s when you consider a claims or policy-based approach.
  • Scopes: In the context of OAuth2/OIDC, a scope is a string that represents a permission or set of permissions that a client is requesting on behalf of a user. For example, openid, profile, email are OIDC scopes for basic profile info. Or an API might define scopes like read:messages or write:files. Scopes end up as claims in the access token (often a claim named scp or scope containing a space-delimited list of scopes). In an ASP.NET Core Web API, you’ll commonly check the token’s scopes to authorize certain endpoints. For instance, if you use Azure AD to protect an API, you might define App Roles or Delegated Permissions in Azure, which appear as roles or scopes in the token. You can then write a policy: options.AddPolicy("Files.Write", p => p.RequireClaim("scp", "Files.Write")). This requires that the token presented includes the “Files.Write” scope. If using the ASP.NET Core JWT middleware with Azure AD, there’s also an extension method (in Microsoft.Identity.Web) AddAuthorization(options => { options.Initializer.AddAzureADBearer(...).AddPolicy("ScopePolicy", p=>p.RequireScope("Files.Write")); }) that simplifies this. In summary, scopes are like coarse-grained permissions delegated to the client from the user. They are not user-specific attributes, but rather rights the user (resource owner) has consented to give to the client. In terms of implementation: treat them as claims and use RequireClaim or the Microsoft provided helpers to enforce.

  • Policy-based Authorization: Policies in ASP.NET Core are a flexible way to express authorization rules in code. A policy has a name and one or more requirements, and you can register them in AddAuthorization. For example, you might create a policy named "EmployeeOnly" that requires the claim "EmployeeID" to exist. Or a policy "AtLeast21" that requires a claim "DateOfBirth" such that the user is 21 years or older. Policies are evaluated by authorization handlers which you can write if the logic is complex (like checking multiple claims or external resources).

    • Roles and Claims via Policy: Roles and scopes as mentioned can actually be enforced via policies internally (and they often are, under the covers). [Authorize(Roles="Admin")] and [Authorize(Policy="AdminPolicy")] could do similar things. Microsoft encourages developers to use policy-based auth for complex scenarios. For simple role checks, the [Authorize(Roles=...)] is fine. For anything else, define a policy. Example:
      options.AddPolicy("AdminOrManager", policy => 
          policy.RequireRole("Administrator","Manager"));
      or
      options.AddPolicy("HasBadge", policy => 
          policy.RequireClaim("EmployeeBadge", "True"));
      Then [Authorize(Policy="HasBadge")] on a controller enforces that requirement.
    • Resource-based policies: ASP.NET Core also allows you to pass the resource being accessed to the policy. This is more advanced, used when e.g. you want to check if the user has access to a specific entity (like checking if a user is the owner of a record). This involves imperative authorization (AuthorizationService.AuthorizeAsync(user, resource, "PolicyName")) and writing a handler that examines the resource. That might be relevant in documentation for fine-grained auth (like permissions at a data level).
  • Permissions: The term "permissions" can mean different things, but generally it refers to fine-grained actions a user can perform (sometimes overlapping with scopes or claims or roles). For example, you might define application-specific permissions like “CanPublishArticles”, “CanDeleteComments”. How do you implement that? There’s no built-in “Permission” object in ASP.NET Core, but you can implement it via claims or roles:

    • One approach: Use a claim for each permission. e.g., a user might have claims perm=CanPublishArticles, perm=CanDeleteComments. You can then have a policy for each permission: policy.RequireClaim("perm", "CanPublishArticles"). This is easy to check but might lead to a lot of claims if a user has many permissions.
    • Another: Use roles to group permissions (if roles fit). e.g., “Editor” role implies publish and edit rights; “Moderator” role implies delete comments, etc. Then check roles.
    • Another: Just define a policy with custom logic. Perhaps your user has some “PermissionLevel” claim or you maintain a lookup somewhere; your handler can examine User and external data to decide if the permission is granted.
    • The key difference is that roles are meant to be coarse and tied to identity, whereas permissions can be very granular and numerous. For maintainability, sometimes a combination is used (role grants a bunch of permissions, plus some individual overrides as claims).
    • In official docs, roles and claims cover 95% of use cases. The concept of “permissions” as a separate entity is up to the developer’s design.

How to configure these in ASP.NET Core:

  • When using ASP.NET Core Identity, you call AddDefaultIdentity or similar which by default sets up authentication and authorization to use Identity’s user store and roles if enabled. Identity will populate the user’s claims (including roles) when they sign in. If you want to add custom claims for a user from the Identity database (like user’s department), you might use a claims transformer or include it in the cookie on sign-in (there are events you can hook, or you store those in the user’s profile and query them as needed).
  • Without Identity, if using only external IdP, the claims come from the tokens. The JWT handler will populate ClaimsPrincipal with claims from the JWT (by default it even maps some standard JWT claims to .NET claim types, e.g., sub -> ClaimTypes.NameIdentifier). You can disable or adjust the mapping if needed (JwtBearerOptions.MapInboundClaims = false if you want raw claim types).
  • You add authorization policies in the Services.AddAuthorization call in Program.cs or startup. Also, you can use attributes like [Authorize(Roles="X")] or [Authorize(Policy="Y")] on controllers, Razor pages, or Blazor components.
  • If using Roles with Identity, ensure you called AddRoles<IdentityRole>() when configuring Identity (this adds the role manager and tells Identity to include role claims) (Role-based authorization in ASP.NET Core | Microsoft Learn) (Role-based authorization in ASP.NET Core | Microsoft Learn). Then Identity’s user manager can assign roles to users which are persisted in the database AspNetUserRoles table.
  • If you have a JWT from an external provider that includes roles (say Azure AD can include roles or groups), you might not need Identity at all – you can rely on those claims directly in the token for authZ. For example, Azure AD allows defining app roles and then you can do [Authorize(Roles="SomeRole")] if you configure the JWT token validation to map the role claims correctly.
  • Claims Transformation: If you need to augment or transform claims once the user is authenticated, ASP.NET Core provides IClaimsTransformation. For instance, you could automatically add a “permission” claim based on a user’s role, or fetch extra user data from a DB and add it to claims after login. This runs on every request for that user (or you can cache it). This is useful for things like dynamic permissions or legacy role mapping.

In essence, claims are the foundation, roles and scopes are just particular claims that have special meaning by convention, and policies are how you evaluate those claims (and other conditions) to make authorization decisions. The system is very flexible – you can express simple role checks or complex logic with the policy model. The official recommendation is to prefer policy-based checks in new applications, as they scale better for complex requirements and testing.

Common Security Pitfalls and Best Practices

Implementing authentication and authorization comes with many security considerations. Mistakes can lead to serious vulnerabilities. Here are common pitfalls to avoid and best practices to follow when securing ASP.NET Core applications:

  • Always Use HTTPS: Never transmit authentication credentials or tokens over unencrypted connections. An obvious but critical rule: enable HTTPS on your site and API. In fact, ASP.NET Core templates enforce HTTPS by default. Using plain HTTP for auth is a common mistake to avoid (Authentication and Authorization Best Practices), as an attacker could sniff tokens or cookies in transit. Ensure that cookies have the Secure flag so they’re not sent over HTTP. For APIs, simply do not host them on HTTP in production. (During development, use https://localhost).

  • Protect Secrets and Keys: Never embed sensitive secrets (client secrets, API keys, encryption keys) in frontend code or commit them to source control (Authentication and Authorization Best Practices). Use tools like user-secrets for development and secure environment variables or vaults for production. In authentication, this applies to things like JWT signing keys (which should be properly safeguarded if you manage them) and third-party IdP secrets. If you accidentally leak a secret (like push it to GitHub), assume it’s compromised and rotate it (generate a new one) (Authentication and Authorization Best Practices).

  • Don’t Roll Your Own Crypto or Protocol: Use the built-in frameworks and standard protocols. ASP.NET Core Identity, the Microsoft authentication libraries, or well-known libraries like IdentityServer implement security best practices. Building a custom auth scheme or crypto algorithm is error-prone. A common mistake is implementing custom auth without necessity (Authentication and Authorization Best Practices) – e.g., inventing a new token scheme or writing your own password hashing. Instead, rely on Identity (which by default uses proven password hashing algorithms and user lockout, etc.) or use OAuth/OIDC libraries. Only write custom auth handlers if you absolutely must (and even then, consider a security review).

  • Validate All Tokens Properly: If you use JWTs or other tokens, make sure to validate all aspects of them – signature, expiration, audience, issuer, etc. The JWT middleware by default does this, but if you ever manually parse tokens, do not just decode the JWT and trust its contents without validation. A dangerous anti-pattern: some might use JwtSecurityTokenHandler().ReadToken() or decode the base64 payload and not verify the signature – this can be exploited easily (an attacker could forge a token). Always let the framework validate the token using the known signing keys. Also ensure you configure the expected issuer and audience: for example, if your API only expects tokens from your tenant or your Auth0 domain, set those. The JWT middleware will then reject tokens from a different issuer or wrong audience.

  • Use Strong Identity Store Practices: If using local accounts, store passwords securely (ASP.NET Identity does this by default using hashed+salted passwords with the latest Go-based KDF or PBKDF2). Do not store passwords in plaintext or using weak hashes (MD5, SHA1 are inadequate). Also consider enabling additional protections: account lockout on multiple failed attempts (Identity has this feature to prevent brute force), 2FA/MFA for sensitive accounts, password strength policies, etc. Not enforcing strong passwords or lockout is a pitfall – it makes brute-force or credential stuffing easier (Authentication and Authorization Best Practices).

  • Be Cautious with Persistent Logins: If you allow “Remember Me” (long-lived sessions), understand the risk that if a token or cookie is stolen, it could be used for a long time. Mitigate by using sliding expiration and absolute expiration for cookies appropriately. ASP.NET Core Identity’s cookies by default use a 14-day persistent cookie if “Remember Me” is checked. If security is a bigger concern than convenience, you might shorten that. Also, consider implementing refresh token rotation if you have long-lived refresh tokens on SPAs or mobile (some IdPs handle this for you, ensuring a stolen refresh token can be detected/invalidated).

  • Secure Cookies Properly: If using cookie authentication, set the cookie options to secure defaults:

    • HttpOnly = true (so JavaScript cannot read the cookie and steal it – this helps mitigate XSS impact).
    • SameSite attribute appropriately: Lax or Strict for most apps to prevent CSRF in cross-site contexts. If your app needs cross-site POST (rare for same-site cookies beyond OAuth login flows), then you may allow SameSite=None with Secure. The default in ASP.NET Core was adjusted to Lax to accommodate OAuth logins (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn), but you should evaluate based on your scenario.
    • Cookie.SecurePolicy = Always to force secure (HTTPS).
    • If the app is multi-subdomain and you need the cookie across them, carefully set the domain and consider implications.
    • Use the Cookie Policy Middleware to help apply things globally (and to log or adjust cookie parameters centrally) (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn).
    • Anti-Forgery (CSRF) Tokens: For cookie-authenticated web apps (MVC/Razor), always use the anti-forgery token on state-changing POST forms (the template does this). If you build custom AJAX that modifies server state, include an anti-CSRF header/token (or ensure SameSite cookies protect it). This prevents malicious websites from triggering actions using a user’s cookie (CSRF attack). ASP.NET Core’s [ValidateAntiForgeryToken] and related attributes are your friend.
  • Watch for Open Redirects: If you use the [Authorize] attribute, unauthenticated users get redirected to a login page. Make sure you validate or restrict the ReturnUrl parameter to avoid open redirect attacks (where a user could be tricked into logging in and then sent to a malicious URL). The Identity system’s login page logic usually handles this by checking Url.IsLocalUrl(ReturnUrl) before redirecting. If you implement custom login endpoints, do the same.

  • Limit Exposure of User Data in JWTs: If you design custom tokens, avoid putting sensitive personal data in them. Remember JWTs can be decoded by anyone who intercepts them (if not encrypted). They are only base64 encoded and signed, not encrypted. So don’t include things like plain-text passwords (never), or overly detailed profile info. Include only what you need for authZ decisions. If you have lots of user data needed by APIs, consider that the API might fetch details from a database after auth rather than bloating the token.

  • Use Scopes/Least Privilege: When requesting tokens (from an IdP), request the minimal scopes needed. Don’t ask for broad scopes like admin or write for everything if the app only needs read access, for example. On the API side, check for specific scopes/roles to ensure the token presented has been issued with the right privileges. This prevents a token meant for one thing from accessing another. Also, segment your APIs by audience if needed – e.g., issue separate tokens for different microservices to reduce the blast radius if a token is misused.

  • Implement Logging and Monitoring: It’s best practice to log authentication events (without sensitive info). For example, log failures (with reasons), and obviously log sensitive admin activities. Use ASP.NET Core’s built-in logging to catch unexpected authZ failures or token validation exceptions; these could indicate someone probing your system. Also consider enabling Azure AD or your IdP’s sign-in risk detection if available. From the app side, monitor for unusual patterns (e.g., many 401s could mean someone is trying a token replay or guessing).

  • Keep Authentication Middleware Updated: Stay up to date with the latest ASP.NET Core security updates. The frameworks do get patches for vulnerabilities. Also, if you roll in libraries like IdentityServer or others, keep them updated. Using outdated crypto (like an old IdentityServer with known issues, or an old ASP.NET Core where a vulnerability was patched) is a risk.

  • Disable Legacy Protocols If Not Needed: For example, if you integrate Azure AD, you likely use OIDC (which is OAuth2/OpenID Connect). Azure AD also supports WS-Fed and SAML endpoints for compatibility. If your app doesn’t need those, don’t use or enable them. Similarly, if using IdentityServer, you might choose to not enable the legacy resource owner password flow. Reducing attack surface is always good.

  • Test your authentication flows: Use tools or just manual testing to ensure that unauthorized access is truly blocked. For example, after implementing an API, try calling it without a token or with an expired token – does it correctly return 401? Try calling an admin endpoint with a normal user’s token – is it forbidden? Run through the OAuth flows and see if anything can be tampered with. If you discover you can, say, change the scope parameter in the URL during login to get more privilege than intended, fix your IdP settings to prevent that.

  • Beware of JSON Deserialization (if you accept raw JSON in APIs): While not directly auth, injection attacks through model binding can be a risk if you’re not careful with what you accept. Use model validation and don’t blindly trust any data coming in just because the user is authenticated.

  • Logout and Session Invalidation: Provide a way for users to log out which clears their cookie or token. If using JWTs without server state, you can’t force invalidate a JWT (until it expires), but if you have a refresh token or session list, you could revoke those. For cookie auth, calling SignOut will clear the cookie. One pitfall is not clearing the authentication cookie properly (make sure path and name match when expiring it). Another is when using multiple auth schemes (like cookie + JWT in the same app) – ensure you sign out of all if needed (like if an IdP session also should be logged out via OIDC signout, etc.).

    • Also, consider scenarios like password changes: If a user changes their password or an admin disables an account, you might want to invalidate active sessions. In Identity, updating the SecurityStamp and configuring the cookie validation interval can force users to re-login when such changes occur.
  • Cross-Site Scripting (XSS): This isn’t directly authZ, but an XSS vulnerability in your app can be catastrophic for security, especially for SPAs that store tokens. An XSS could allow an attacker to run JS in the user’s browser, potentially stealing tokens (if in JS accessible storage) or making requests on behalf of the user (which if you rely solely on client-side checks, could be bad). So follow best practices to avoid XSS (encode output, avoid eval, consider a CSP). For Blazor WASM and other SPAs, a well-configured Content Security Policy and using frameworks’ built-in protections go a long way.

Following these best practices will help ensure your authentication and authorization implementation is robust. Security is a broad topic, but focusing on protecting credentials/tokens, using proven libraries, and enforcing least privilege will address many of the common pitfalls.

High-Level Architecture Patterns for Auth in ASP.NET Core

Stepping back, it’s useful to consider the architecture patterns for handling auth in modern applications. The two fundamental patterns we’ve touched on are session-based (cookie) authentication and token-based authentication. Many applications use a combination of both, depending on their components. Let’s compare these patterns and variations (with a visual aid):

(Cookies vs. Tokens: The Definitive Guide) Traditional cookie-based session auth (left) vs modern token-based auth (right). In cookie auth, the server creates and stores a session (or session token in a cookie) after login, and the browser automatically sends the cookie with each request. In token-based auth, the client receives a token (e.g., JWT) and must explicitly send it in an Authorization header for each API request. The right-side also often involves separate domains for SPA and API.

Cookie-Based Session Authentication

In this pattern, the server and client maintain a session using cookies. The steps typically are:

  1. Login: User provides credentials (or goes through an external login flow). Server validates and creates a session – often by serializing the user’s identity into an encrypted cookie stored on the client (How does ASP.NET Core know that cookie is valid in cookie authentication? - Stack Overflow) (How does ASP.NET Core know that cookie is valid in cookie authentication? - Stack Overflow). The server may also keep a session record (e.g., in a database or in-memory store) if not using self-contained cookies.
  2. Requests: The browser automatically attaches the cookie to every request to the server’s domain. The server, via middleware, reads the cookie, deserializes the identity (checks signature to ensure it’s not tampered), and establishes HttpContext.User. This happens for each request without extra effort from the client or server (just the initial overhead of decrypting the cookie).
  3. AuthZ: The server can check User and enforce [Authorize] attributes as needed, knowing that if User is set, the user is authenticated.
  4. Logout: The session is terminated by clearing the cookie (and possibly clearing server-side session if applicable). The browser no longer sends the cookie (or it’s invalid), so the user is signed out.

Pros: Simplicity for the developer (the browser and server handle passing the token via cookie, you don’t manually attach headers in your code). Cookies can be HttpOnly, adding protection. It’s a good fit for web apps where the client and server are the same origin. Also, because the cookie can be considered one big token, it can contain a lot of claims (within size limits) and the server can trust it without database calls (if using self-contained cookies).

Cons: Doesn’t work well across domains (if your API is separate domain from front-end, the cookie won’t be sent to it unless you allow cross-site cookies, which raises CSRF issues). Also, cookies are subject to CSRF since the browser will send them automatically – you must implement anti-forgery measures for state-changing operations. Another con is scaling if using server-stored sessions (but ASP.NET Core’s default cookie auth is stateless, so that’s not a big issue unless you opt into server storage). Additionally, some modern architectures (mobile apps, SPAs from separate origins) can’t use cookies easily – mobile apps don’t automatically store and send cookies unless managed, and cross-origin SPAs face SameSite restrictions.

Use cases: Traditional multi-page web applications (Razor Pages/MVC). Internal enterprise apps where users access via a web browser and SSO with corporate IdP (the app can use OIDC and then maintain its own cookie). Anytime you have a web UI served by ASP.NET Core, cookie auth is typically the go-to method.

Token-Based Authentication (Stateless JWT or similar)

In this pattern, there is no implicit session via cookies. Instead, after authentication, the client is given a token (like a JWT). The client is responsible for storing and presenting that token on each request to the server (usually in the HTTP Authorization header).

Using the diagram’s right side as reference:

  1. Login/Token issuance: The user authenticates (e.g., by sending credentials to an auth endpoint or via an OAuth redirect flow). The server (or IdP) responds with a JSON containing tokens (access token, etc.) instead of setting a cookie. For example, a response might be { "token": "<JWT here>", "refresh_token": "<GUID>" }. The client (browser JS or mobile app) then stores these tokens (in memory, localStorage, or a secure storage area).
  2. Requests: For subsequent API calls, the client must manually include the access token, typically by adding an Authorization: Bearer <token> header. The browser will not attach tokens automatically – the app’s code does it, often using fetch or HttpClient with an authorization header. The server’s JWT middleware sees the header, takes the token, validates it, and if valid populates HttpContext.User.
  3. AuthZ: From here, the authorization is similar – the server uses [Authorize] and policies to check the claims in the token.
  4. Token Refresh: Because tokens have expiry, if a refresh token was provided, the client may at some point call a token refresh endpoint to get a new access token when the old is about to expire. This is an extra moving part compared to cookie-based sessions (where sliding expiration can happen transparently on the server). The refresh flow must be handled carefully (store refresh token securely, rotate it if possible to avoid use of stolen refresh tokens).
  5. Logout: Clearing a token on the client simply means removing it from storage so it’s not sent anymore. There’s no server-side state to clear (unless you maintain a token revocation list). You might call an IdP’s revoke endpoint to invalidate the refresh token.

Pros: It’s stateless on the server – any server node with the public key can validate the JWT, no session persistence needed. It’s also inherently suited for APIs and microservices, where clients other than browsers might call (mobile apps, other servers). For cross-origin scenarios, tokens are not automatically sent, which means you avoid CSRF by design (an attacker’s website can’t force the user’s browser to attach a token in an Authorization header – they don’t have access to it if stored properly). Also, multiple different APIs on different domains can all accept the same token (if they share the issuer trust), which is great for distributed systems.

Cons: Puts more responsibility on the client-side: developers must implement storage and transmission of tokens. If done incorrectly, it can introduce vulnerabilities (e.g., storing JWT in plain localStorage is susceptible to XSS theft; or not using HTTPS would leak tokens). Also, revocation of stateless tokens is tricky – you either accept the time window until expiry or implement an infrastructure (like an revoke list or short expiration + refresh). Another con is performance: parsing and validating a JWT is very fast and usually negligible, but it is more data being sent each request compared to a small session cookie ID. And if the token is large (many claims), that’s extra bandwidth each call. Usually fine, but it’s a consideration in some high-throughput cases.

Use cases: SPAs and mobile apps are the biggest uses. Also any scenario where your frontend is decoupled from the backend and/or you want a unified auth across many services. If you have say a React app, a mobile app, and maybe allow third-party integrations, issuing JWTs from a central IdP that all your APIs trust means any of those clients can call any service with the token. Another use case is machine-to-machine (client credentials) – there’s no browser, so a token is the natural choice (e.g., a nightly job gets a JWT to call an API).

Combining Patterns and Other Architectures

Many real-world solutions use both patterns in tandem:

  • Cookie + JWT: An ASP.NET Core server-rendered app might use a cookie for its own session, but also obtain a JWT to call a downstream API. Or it might set a JWT as a cookie (for legacy reasons) but typically it would just keep the JWT in memory or a server-side cache if needed.

  • BFF (Backend for Frontend): This is a variant where the front-end (browser JS) does not directly get a token. Instead, when the user logs in, the token is stored in a secure http-only cookie by the server (the BFF). The React/Angular app then communicates with the BFF (on the same domain, so cookie is automatically sent) for any API data, and the BFF internally adds the token when calling the actual API. In effect, the SPA uses cookie auth with its BFF, and the BFF uses token auth with other APIs. This pattern tries to get the best of both worlds: it avoids exposing tokens to the browser JavaScript (mitigating XSS risk), and it leverages the ease of cookies for the browser-BFF communication, while still being stateless or token-based for the BFF-backend communication. ASP.NET Core can serve as a BFF: for example, use AddCookie for the front-end, and also use AddOpenIdConnect or a manual OAuth flow to obtain tokens from an IdP, storing them server-side (perhaps in the cookie or in a server cache keyed by session). The BFF endpoints would require the cookie (ensuring the user is logged in) and then use HttpClient with the bearer token to fetch data from other APIs. The Duende BFF library (by the IdentityServer folks) provides an out-of-the-box implementation of this concept for .NET. This is a relatively new but increasingly popular architecture for SPAs that need high security.

  • Microservices with a Gateway: You might have many microservice APIs and one gateway (perhaps using YARP or Ocelot in .NET). The gateway could handle authentication (validate JWT or handle cookie -> token translation) and then forward the request to downstream services, perhaps with a stripped-down token or using an internal token. For docs purposes, just know that architecture exists. In such cases, each microservice might trust a token issued by the gateway or the original IdP.

  • SignalR authentication: If you use SignalR (or any persistent connection) along with token auth (like for a SPA), you can send the JWT as part of the connection negotiation. ASP.NET Core allows JWT authentication to apply to WebSockets/SignalR as well. Or if using cookies, it just uses the cookie. This is just a note that real-time hubs integrate into the same auth framework.

High-Level Trade-offs: A quote from an Okta developer article encapsulates a key difference: “The main difference between cookies and tokens is their nature: tokens are stateless while cookies are stateful.” (A Comparison of Cookies and Tokens for Secure Authentication | Okta Developer). Cookie auth is considered stateful because typically the server maintains a session or at least depends on the cookie for state; token auth is stateless as each request is self-contained with auth info. Neither is universally better – it depends on your app type and needs.

For a new ASP.NET Core app, you might use:

  • Cookie auth if it’s a standard web app that doesn’t need to call a lot of separate APIs and you want the simplicity of built-in login pages and Identity.
  • Token auth (with maybe a IdP like IdentityServer, Auth0, etc.) if you are building an API for use by SPAs or mobile.
  • Or even combine: e.g., an app that has a web UI for admin (cookie auth) and also exposes APIs for clients (JWT auth). ASP.NET Core’s flexibility with authentication schemes makes it possible to support multiple at once (just be careful to specify default schemes and such so they don’t conflict).

Best of Both: If you can, use the approach that leverages the platform security features. For example, if building a pure web app (Razor Pages), use cookie auth – it’s battle-tested and straightforward. If building a SPA, strongly consider using an existing IdP (like Azure AD B2C, Auth0, etc.) and their libraries, or the BFF pattern, instead of manually handling too much token logic.

Finally, always design with the assumption that networks are hostile. Validate everything, principle of least privilege for tokens, and compartmentalize your application into distinct components (IdP vs API vs UI) with clear trust boundaries. With ASP.NET Core’s rich identity and security ecosystem, you have the tools to implement these patterns securely – and this foundational understanding of concepts and terminology will guide you as you overhaul the official docs for authentication, authorization, and identity in ASP.NET Core.

Workshop: Secure a .NET Aspire App with Authentication & Authorization

In this hands-on workshop, we will progressively add authentication and authorization to a .NET 8 Aspire starter application. The base app consists of a Blazor Server frontend and an ASP.NET Core Web API backend (two projects orchestrated by an Aspire AppHost). We’ll start from an unauthenticated app and build up to using ASP.NET Core Identity (local accounts) and external identity providers (Keycloak and Microsoft Entra ID/Azure AD), then implement role-based and policy-based authorization. Each step includes conceptual explanation, code to modify (with copy-paste examples), and how to verify the outcome.

Prerequisites: .NET 8 or 9 SDK installed (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn) (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn), and Docker installed for the Keycloak exercise (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn). Familiarity with basic ASP.NET Core and Blazor is helpful, but no prior auth experience is assumed.

Step 1: Set Up the Unauthenticated Aspire App (Starter Template)

Goal: Create and run the base application with no authentication, to understand the starting point.

  1. Create the Aspire solution: Install the Aspire templates and generate a new app. In a terminal, run:

    dotnet new install Aspire.ProjectTemplates  # install Aspire templates ([Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/build-your-first-aspire-app#:~:text=If%20you%20haven%27t%20already%20installed,command))
    dotnet new aspire-starter --output AspireWorkshopApp  # create new Aspire app (no auth yet)

    This creates a solution AspireWorkshopApp with multiple projects. The relevant ones are:

    • AspireWorkshopApp.Web – Blazor Server frontend (UI).
    • AspireWorkshopApp.ApiService – ASP.NET Core minimal Web API (backend).
    • AspireWorkshopApp.AppHost – Orchestrator project to run the app.
  2. Examine the architecture: The Blazor Web frontend calls the API for data (e.g. weather forecast) using an HttpClient with service discovery. In the default template, the Weather page fetches data from the API endpoint /weatherforecast without any auth or restrictions (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn) (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn). Both the Web and API projects are started together by the AppHost (which orchestrates their communication) (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn) (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn). At this stage, all pages and API endpoints are publicly accessible with no authentication.

  3. Run the app: Open the solution in VS Code or Visual Studio. If using Visual Studio, set AspireWorkshopApp.AppHost as the startup project (this will launch the orchestrator which in turn hosts the API and Web) (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn). Run the application (F5 or dotnet run). In your browser, navigate to the app URL (likely https://localhost:5001 or a similar port). You should see the home page and can navigate to the Weather page. The Weather page displays forecast data retrieved from the API with no login required (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn).

  4. Verify behavior: Confirm that you can access all pages freely. For example, the Weather page should show a table of weather data. This confirms the app is working and unauthenticated. We will now begin adding authentication.

Step 2: Add Cookie Authentication (Blazor Server Frontend)

Concept: Enable authentication in the Blazor Server app using cookie-based auth (without Identity, to grasp the fundamentals). We’ll protect a page so it requires login, and implement a simple login flow issuing an auth cookie.

  1. Add Authentication services: In the Blazor Web project (AspireWorkshopApp.Web), open Program.cs and register the cookie auth scheme:

    // Program.cs (AspireWorkshopApp.Web)
    using Microsoft.AspNetCore.Authentication.Cookies;
    var builder = WebApplication.CreateBuilder(args);
    // ... existing setup ...
    
    // 1. Add Authentication + Cookie
    builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddCookie(options => {
            options.LoginPath = "/login";        // redirect here for unauthenticated
            options.AccessDeniedPath = "/";      // (optional) redirect on forbidden
        });
    builder.Services.AddAuthorization();  // Add authorization services
    builder.Services.AddHttpContextAccessor(); // allow accessing HttpContext in Blazor
    // ... existing builder.Services calls ...
    
    var app = builder.Build();
    // ... existing middleware ...
    // 2. Enable auth middleware
    app.UseAuthentication();
    app.UseAuthorization();
    // ... existing app.Map... calls ...
    app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
    app.MapDefaultEndpoints();
    app.Run();

    This registers cookie authentication as the default. The LoginPath is set to /login, meaning unauthenticated users will be redirected there (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn) (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn). We also ensure the middleware UseAuthentication() and UseAuthorization() are called before mapping endpoints (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn) (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn).

  2. Protect a page with [Authorize]: We’ll require login for the Weather page. Open Weather.razor (in AspireWorkshopApp.Web/Pages or similar) and add the @attribute [Authorize] directive at the top:

    @page "/weather"
    @attribute [Authorize]
    @inherits WeatherBase  <!-- (the code-behind or base class, if any) -->
    <!-- ... existing page markup ... -->

    Now, accessing /weather will trigger the auth system. Because we set up cookie auth, an unauthenticated user will get redirected to the login path.

  3. Create a login endpoint: For simplicity in this step, we’ll implement a minimal login that automatically signs in a test user. In Program.cs, map a temporary login route before the Blazor components:

    // Program.cs (AspireWorkshopApp.Web) – add after UseAuthorization():
    app.MapGet("/login", async context => {
        // Simulate a login (no credentials check for now)
        var claims = new List<Claim> { new Claim(ClaimTypes.Name, "TestUser") };
        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);
        await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
        context.Response.Redirect("/");  // redirect to home (or use ReturnUrl if present)
    });

    This endpoint signs in a user with username “TestUser” using the cookie scheme (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn) (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn). In a real app, you’d validate credentials (e.g., check a DB) before calling SignInAsync, but here we assume success to focus on the mechanics. After signing in, we redirect to the home page.

  4. Run and test: Restart the app. Try accessing the Weather page. Now, because it’s protected, the app will redirect you to /login (our LoginPath) (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn) (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn). The /login endpoint will immediately sign you in and redirect back. After that, you should be able to see the Weather page content. You can verify the login by checking that User.Identity.Name in the Blazor app is “TestUser” (we will display this in the next step).

    What’s happening? The Blazor Server app now uses cookie authentication. The first time you hit an [Authorize] page, the middleware issues a 302 redirect to the login page. Our fake login sets an auth cookie in the browser, and the user is authenticated on subsequent requests (Use cookie authentication without ASP.NET Core Identity | Microsoft Learn). We used a hardcoded user for now; next, we’ll integrate a proper Identity system for real user accounts.

Step 3: Introduce ASP.NET Core Identity (Local Accounts)

Concept: ASP.NET Core Identity is a full-featured membership system for managing users, passwords, roles, etc. We will add Identity to the app so users can register and log in with a username/password stored in a local database (Introduction to Identity on ASP.NET Core | Microsoft Learn). This replaces our dummy login with a robust system.

  1. Add Identity packages: In the Blazor Web project, add the Identity and Entity Framework Core packages. Run these in the project directory:

    dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
    dotnet add package Microsoft.AspNetCore.Identity.UI
    dotnet add package Microsoft.EntityFrameworkCore.Sqlite

    We’ll use SQLite for the Identity data store (for simplicity, it’s file-based and requires no separate server).

  2. Configure Identity DbContext: Create a new class ApplicationDbContext in the Web project (e.g., under a Data folder):

    // ApplicationDbContext.cs
    using Microsoft.AspNetCore.Identity;
    using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore;
    public class ApplicationDbContext : IdentityDbContext<IdentityUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options) { }
    }

    This context will manage the Identity tables (Users, Roles, etc.).

  3. Register Identity in Program.cs: Open Program.cs (Web project) and modify it to use Identity:

    // Program.cs (AspireWorkshopApp.Web)
    using Microsoft.EntityFrameworkCore;
    using Microsoft.AspNetCore.Identity;
    // ... inside builder.Services configuration:
    var connectionString = $"Data Source={System.IO.Path.Combine(builder.Environment.ContentRootPath, "AspireIdentity.db")}";
    builder.Services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlite(connectionString));
    builder.Services.AddDefaultIdentity<IdentityUser>(options => {
            options.SignIn.RequireConfirmedAccount = false; // disable email confirmation requirement for demo
        })
        .AddRoles<IdentityRole>()              // enable roles if needed
        .AddEntityFrameworkStores<ApplicationDbContext>();
    builder.Services.AddRazorPages(); // Identity UI uses Razor Pages

    Here we configure EF Core to use a SQLite database file named AspireIdentity.db, and we add the Identity services with default UI. AddDefaultIdentity<IdentityUser> sets up the user store and cookie auth for us (Introduction to Identity on ASP.NET Core | Microsoft Learn). We also call .AddRoles<IdentityRole>() so we can use roles later. We include AddRazorPages() because the Identity system’s UI (login, register pages) is provided as Razor Pages in an area.

    Note: The default RequireConfirmedAccount was set to true in templates; we disabled it so users can log in without email confirmation in this workshop (Introduction to Identity on ASP.NET Core | Microsoft Learn).

  4. Adjust authentication setup: Identity adds its own cookie authentication (with scheme "Identity.Application"). Remove or adjust the earlier cookie config to avoid duplication. Specifically:

    • If you added AddAuthentication().AddCookie() in Step 2, you can remove that or ensure it doesn’t conflict. Since AddDefaultIdentity already configures the app cookie, it’s safe to remove our manual AddCookie line from Step 2.
    • Keep UseAuthentication() and UseAuthorization() in the pipeline (Identity requires them too).

    Identity UI also defines a default login path (/Identity/Account/Login) and access denied path, so our previous LoginPath = "/login" is superseded by Identity’s settings.

  5. Apply database migrations: Create the Identity schema in the SQLite DB. In a terminal, run:

    dotnet ef migrations add InitIdentitySchema -c ApplicationDbContext -p AspireWorkshopApp.Web -s AspireWorkshopApp.Web
    dotnet ef database update -c ApplicationDbContext -p AspireWorkshopApp.Web -s AspireWorkshopApp.Web

    This uses Entity Framework Core Tools to create the necessary tables. (Ensure the EF tools are installed by running dotnet tool install --global dotnet-ef if needed.)

  6. Run and register a user: Restart the application. You should now see new pages for Identity. For example, click the Register or Login link (we’ll add visible links in the UI soon, but you can navigate to /Identity/Account/Register). Create a new account with email and password. After registering, the app should log you in automatically. (The Identity UI uses email as the username by default (Introduction to Identity on ASP.NET Core | Microsoft Learn).)

  7. Verify login: After signing up, you should be able to navigate to the protected Weather page without hitting the fake /login route, since you now have a valid Identity cookie. Our dummy /login route from Step 2 can be removed or ignored at this point. The User.Identity.Name should reflect the email/username of your new account.

    Summary: We integrated ASP.NET Core Identity. The app now has a local user store (backed by SQLite) and the standard Identity UI for login & registration. We can create accounts and authenticate with cookies. Next, we’ll display user info in the Blazor UI and then integrate external providers.

Step 4: Display Logged-in User Info in the Blazor UI

Concept: Use Blazor’s built-in support for showing content based on the authentication state. We’ll update the UI to show a “Hello, [username]” message and provide login/logout links.

  1. Provide authentication state to Blazor: The Identity system’s cookie flows run on the server. Blazor Server can access the HttpContext.User. In _App.cshtml or when mapping the Blazor hub, it’s already configured to pass the authenticated user to Blazor components. (If using .NET 8 Razor Components, it does this by default via MapRazorComponents<App>().AddInteractiveServerRenderMode().)

  2. Add an Auth view in the layout: Open MainLayout.razor (or a NavMenu component in AspireWorkshopApp.Web/Components if provided by the template). We’ll use <AuthorizeView> to conditionally display UI:

    @using Microsoft.AspNetCore.Components.Authorization
    <header>
        <!-- ... other layout markup ... -->
        <AuthorizeView>
            <Authorized>
                <span>Hello, @context.User.Identity.Name!</span> 
                <form action="/Identity/Account/Logout" method="post" style="display:inline">
                    <button type="submit">Log out</button>
                </form>
            </Authorized>
            <NotAuthorized>
                <a href="/Identity/Account/Register">Register</a> | 
                <a href="/Identity/Account/Login">Log in</a>
            </NotAuthorized>
        </AuthorizeView>
    </header>

    Explanation: The <AuthorizeView> component shows the <Authorized> content only when the user is authenticated, and the <NotAuthorized> content otherwise (Authorization,Authentication in Blazor TreeView Component). Here we display the current user’s name (@context.User.Identity.Name) when logged in, and a Logout button. The logout is implemented by posting to Identity’s default logout endpoint (/Identity/Account/Logout). We also provide links to Register and Login for unauthenticated visitors.

    Now the Blazor UI will reflect authentication status dynamically.

  3. Test the UI behavior: Run the app and log in with your Identity account (from Step 3). You should now see “Hello, YourEmail!” in the header and a Logout button. Try the logout: clicking it will log you out (it posts to the server to clear the cookie) and redirect to the home page. The UI should then switch to showing “Register | Log in” again. This confirms the app can display user identity info and handle logout.

Step 5: Add External Identity Providers (OIDC Integration)

Concept: Many apps let users log in via external Identity Providers (IdPs) like Keycloak (open-source IdP) or Azure AD (Microsoft Entra ID), instead of or in addition to local accounts (Introduction to Identity on ASP.NET Core | Microsoft Learn). We will integrate both:

  • Keycloak: running locally in Docker (simulating a corporate or third-party IdP using OpenID Connect).
  • Entra ID (Azure AD): using Microsoft’s cloud identity platform.

Both use OpenID Connect (OIDC) under the hood. Our app will act as an OIDC client, redirecting users to the provider for login, then accepting an identity token that confirms their identity. We’ll configure our ASP.NET Core authentication to use OIDC schemes for Keycloak and Azure, and add login buttons for them.

5.1 Keycloak (OpenID Connect) Integration

Setup Keycloak Server:

  1. Run Keycloak in Docker: Use the official Keycloak image. In a terminal, start a Keycloak container:

    docker run -p 8080:8080 \
        -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
        -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
        quay.io/keycloak/keycloak:21.1.1 start-dev

    This launches Keycloak on port 8080 and creates an initial admin user (admin/admin) (Docker - Keycloak). The start-dev mode is for development use (disables HTTPS requirement, etc.).

  2. Create a realm and client:

    • Open the Keycloak admin console at http://localhost:8080 and log in with admin/admin (Docker - Keycloak).
    • Click the dropdown in the top-left (which says “Master” realm) and select Create Realm (Docker - Keycloak). Name it AspireRealm (or any name) and click Create.
    • In the new realm, go to Clients and click Create Client. Set Client ID to AspireApp and Client Type to OpenID Connect. Click Next.
    • For Client authentication, enable Client authentication (Keycloak will treat this as a confidential client with a secret). For Authentication flow, keep defaults. Click Save.
    • After creation, you’ll see settings for this client. Set Access Type to confidential if not already. Generate a secret: on the Credentials tab, note the Client Secret (you might have to click Regenerate Secret to get one).
    • Under Settings for the client, find Valid Redirect URIs. Enter your app’s URL with the OIDC callback path, e.g.:
      https://localhost:5001/signin-keycloak
      (We will use /signin-keycloak as the callback in our app configuration for Keycloak. If your development URL/port is different, use that. You can use http://localhost:5000/* for a broad match in dev since we’re in dev mode.)
    • Save the client settings.
  3. Create a test user in Keycloak: In the Keycloak realm, go to Users > Add User. Create a user (e.g., username alice). After creating, go to the Credentials tab for that user and set a password (and disable temporary flag) (Docker - Keycloak).

Configure our App for Keycloak OIDC:

  1. Add OpenID Connect handler: In our Blazor Web Program.cs, configure an OIDC authentication scheme for Keycloak:

    using Microsoft.AspNetCore.Authentication.OpenIdConnect;
    // ... inside builder.Services, after AddDefaultIdentity:
    builder.Services.AddAuthentication() // (Identity already added cookies)
        .AddOpenIdConnect("Keycloak", options => {
            options.Authority = "http://localhost:8080/realms/AspireRealm";
            options.ClientId = "AspireApp";
            options.ClientSecret = "<Your Keycloak Client Secret>";
            options.CallbackPath = "/signin-keycloak";
            options.RequireHttpsMetadata = false;        // Keycloak dev runs http
            options.GetClaimsFromUserInfoEndpoint = true;
            options.SaveTokens = true;
            options.Scope.Clear();
            options.Scope.Add("openid");
            options.Scope.Add("profile");
            options.Scope.Add("email");
            options.TokenValidationParameters.NameClaimType = "preferred_username";
            options.TokenValidationParameters.RoleClaimType = "roles"; // we'll map roles later
            options.SignInScheme = IdentityConstants.ExternalScheme;
        });

    Breakdown:

    • Authority: the base URL of the Keycloak realm’s OIDC issuer. (Our realm is AspireRealm, so the issuer is http://localhost:8080/realms/AspireRealm.)
    • ClientId/ClientSecret: match the client we created in Keycloak.
    • CallbackPath: the route in our app where Keycloak will redirect after login. We set /signin-keycloak (as used in Keycloak’s redirect URI).
    • RequireHttpsMetadata = false allows using HTTP for metadata (since our Keycloak dev instance is not on HTTPS).
    • We request scopes openid profile email to get user info claims (Configure OpenID Connect Web (UI) authentication in ASP.NET Core | Microsoft Learn).
    • SaveTokens = true stores the tokens (ID token, access token) in the authentication session so we can retrieve them later (for API calls).
    • We map the NameClaimType to preferred_username (Keycloak’s token uses this claim for the username by default) so that User.Identity.Name becomes the Keycloak username (Configure OpenID Connect Web (UI) authentication in ASP.NET Core | Microsoft Learn).
    • We set SignInScheme = IdentityConstants.ExternalScheme. This is important to integrate with Identity’s login flow – it tells the OIDC handler to put the external login result in the Identity system’s external cookie, so Identity can finalize the sign-in (Net Core 3.1 OpenIdConnect with AWS Cognito - Stack Overflow).

    Note: Ensure the OpenID Connect handler is added after Identity in configuration. We call AddAuthentication().AddOpenIdConnect so it hooks into the existing authentication system. By default, Identity’s cookie is the default signin scheme, and we use the external scheme for OIDC.

  2. Expose a login option for Keycloak: Update the UI to allow choosing Keycloak login. For example, in the login page or our layout, add a link to trigger Keycloak challenge:

    • Simplest approach: add another link in the <NotAuthorized> section of MainLayout, e.g.:
      <a href="/Identity/Account/Login?provider=Keycloak">Log in with Keycloak</a>
      Identity’s default Login page will pick up the provider query and redirect to the external login automatically (alternatively, one could use a direct challenge redirect). You could also edit the Identity login page to show an external login button, but for simplicity using the query is fine.
    • Alternatively, navigate to /Identity/Account/ExternalLogin?provider=Keycloak to initiate the external login flow.
  3. Test Keycloak login: Run the app and click "Log in with Keycloak." This should redirect you to the Keycloak realm’s login page. Log in as the user you created (e.g., alice + password). After successful authentication, Keycloak will redirect back to https://localhost:5001/signin-keycloak. ASP.NET Core will process the OIDC response, create an Identity user (by default, Identity will provision a user record for the external login) or use an existing linked user, and log you in with a local Identity cookie. Finally, you’ll be redirected to the return URL (e.g., home page).

    Verification: You should now see “Hello, alice!” (or the Keycloak username) in the Blazor UI, indicating the external login succeeded. The user is actually logged into our app via Identity, but using Keycloak as the authentication source. In the ASP.NET Core Identity database, a user record is created with a random username (GUID) and an entry in AspNetUserLogins linking to the Keycloak provider. (This behavior is part of Identity’s default external login flow.)

    Now our app supports Keycloak OIDC login alongside local Identity accounts.

5.2 Microsoft Entra ID (Azure AD) Integration

Next, we integrate Azure AD (Entra ID) as another external OIDC provider. This allows Azure AD users (organizational accounts or MSA) to log into our app.

Azure AD App Registration:

  1. Register an app in Azure: Go to the Entra ID/Azure AD portal (Azure Portal > Azure AD). Navigate to App Registrations and create a New Registration (Quickstart: Register an app in Microsoft Entra ID - Microsoft identity platform | Microsoft Learn). Give it a name (e.g., "Aspire Workshop App"), and choose Accounts in this organizational directory only if using a single tenant (or multi-tenant as needed) (Quickstart: Register an app in Microsoft Entra ID - Microsoft identity platform | Microsoft Learn). For Redirect URI, add a Web redirect URL:
    https://localhost:5001/signin-azure
    (We’ll use /signin-azure for Azure AD’s callback). Complete the registration.

  2. Configure permissions (optional): The new app by default has User.Read delegated permission for Microsoft Graph. We won’t need to call Graph in this workshop, so no change needed. You may grant admin consent for User.Read if testing with guest accounts, but it’s not necessary for basic sign-in.

  3. Create a client secret: In Azure AD app registration, go to Certificates & Secrets and add a New client secret. Copy the secret value (you will need it only once now, as it becomes hidden).

    Alternatively: If you prefer not to use a client secret (which is needed for a confidential client in server-side code flow), you could configure a public client with PKCE. However, for simplicity we treat our Blazor Server as a confidential client with a secret.

Configure App for Azure AD OIDC:

  1. Add Azure AD authentication scheme: In Program.cs (Web), add another .AddOpenIdConnect for Azure:

    builder.Services.AddAuthentication()
        .AddOpenIdConnect("AzureAD", options => {
            options.Authority = "https://login.microsoftonline.com/<YourTenantID>/v2.0";
            options.ClientId = "<Your Azure AD App ClientId>";
            options.ClientSecret = "<Your Azure AD Client Secret>";
            options.CallbackPath = "/signin-azure";
            options.ResponseType = "code";
            options.SaveTokens = true;
            options.Scope.Clear();
            options.Scope.Add("openid");
            options.Scope.Add("profile");
            options.Scope.Add("email");
            options.TokenValidationParameters.NameClaimType = "name";
            options.TokenValidationParameters.RoleClaimType = "roles";
            options.SignInScheme = IdentityConstants.ExternalScheme;
        });

    Fill in your Tenant ID (GUID for your Azure AD tenant) in the Authority URL, or use common or organizations if appropriate for multi-tenant. Use the Application (client) ID from the registration for ClientId, and the secret for ClientSecret. The configuration is similar to Keycloak’s, except:

    • Authority is the Azure AD login endpoint with v2.0 (which supports OIDC and OAuth2 v2). For example, if your tenant ID is abcd-efgh-..., Authority = https://login.microsoftonline.com/abcd-efgh-.../v2.0.
    • We set ResponseType = "code" (which is default) for the OIDC code flow.
    • We request basic scopes openid profile email. Azure AD will include an email claim for Microsoft Accounts or Azure AD B2C, but for work accounts the primary email might come as preferred_username. We set NameClaimType to "name" (Azure AD provides the user’s display name in the name claim in the ID token).
  2. Add login link for Azure: Similar to Keycloak, add a UI option. For instance:

    <a href="/Identity/Account/Login?provider=AzureAD">Log in with Azure AD</a>

    (Ensure the provider name matches the scheme name "AzureAD" we used.)

  3. Test Azure AD login: Run the app and click "Log in with Azure AD." You will be redirected to Microsoft’s login page. Log in with a user from the Azure AD tenant you registered the app in (or a guest if allowed by your app’s settings). After consent (if first time) and login, Azure will redirect back to /signin-azure. The ASP.NET Core OIDC handler will process the authentication and sign you into Identity (just like with Keycloak). You should then see “Hello, [Your Name]!” in the app.

    Troubleshooting: If you get an AAD error about redirect URI mismatch (AADSTS50011), double-check that the redirect URI in the Azure app registration exactly matches the CallbackPath and domain (including scheme/port) of your app (Quickstart: Register an application in Microsoft Entra ID). Also ensure the app is running on HTTPS (the dev certificate should be trusted (Build your first .NET Aspire solution - .NET Aspire | Microsoft Learn)).

Now we have two external providers integrated. Identity’s login page will allow local login (email/password) or either of the external options. Our UI links provide a quick way to trigger external logins directly. We can authenticate users via Keycloak or Azure AD, with our app accepting the OIDC tokens and creating an Identity user session.

Step 6: Protect API Endpoints (Authentication for the Web API)

So far, authentication has been enforced in the Blazor UI (frontend pages). Our backend API (AspireWorkshopApp.ApiService) is still wide open (anyone could call the weather forecast endpoint). In this step, we secure the API endpoints so that only authenticated users can access them, and ensure our Blazor frontend passes the appropriate credentials when calling the API.

  1. Add authentication to the API project: Open Program.cs in AspireWorkshopApp.ApiService. Register JWT Bearer authentication, since the API will trust tokens issued by our identity providers (Keycloak/Azure AD or our local Identity if we issue JWTs). For example:

    // Program.cs (AspireWorkshopApp.ApiService)
    using Microsoft.AspNetCore.Authentication.JwtBearer;
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options => {
            // Accept tokens from either Keycloak or Azure AD
            options.Authority = "https://login.microsoftonline.com/<TenantID>/v2.0"; 
            options.TokenValidationParameters.ValidIssuer = "https://login.microsoftonline.com/<TenantID>/v2.0";
            options.TokenValidationParameters.ValidateAudience = false; // (for demo: accept any aud)
            // We could also add Keycloak as additional issuer manually if needed.
        });
    builder.Services.AddAuthorization();
    // ... existing services like builder.Services.AddServiceDefaults() if any ...
    var app = builder.Build();
    app.UseAuthentication();
    app.UseAuthorization();
    // ... map endpoints ...
    app.MapGet("/weatherforecast", () => /* ... return weather data ... */)
       .RequireAuthorization();
    app.Run();

    Let’s break down a few points:

    • We add JWT Bearer auth. In a real scenario, if we wanted to accept tokens from both Azure AD and Keycloak, we might need to configure multiple JWT options or a custom validation. For simplicity, the above config trusts Azure AD tokens and does not enforce audience (ValidateAudience=false), meaning it will accept any JWT from that issuer. We could similarly trust Keycloak by either adding another JwtBearer with a different Authority or by relaxing issuer checks. For a production app, you’d scope this down (e.g., require a specific audience or use separate authentication schemes for each issuer).
    • We apply RequireAuthorization() to the weather forecast endpoint (assuming it’s a minimal API endpoint) so that an incoming request must be authenticated (Introduction to Identity on ASP.NET Core | Microsoft Learn). If not, a 401 Unauthorized is returned.

    Note: The Aspire template may have a MapDefaultEndpoints() or similar that maps the Weather API. If so, ensure that code applies authorization. Alternatively, convert the endpoint mapping to use an explicit .RequireAuthorization() as shown.

  2. Configure token issuance (client side): When our Blazor Server calls the API, it needs to include an access token in the request header. With our current setup:

    • If the user logged in via Keycloak or Azure AD, we configured SaveTokens = true. This means we can retrieve the access token issued by the IdP (if any) from the user’s authentication cookie. By default, Azure AD (v2) will issue an ID token (for authentication) and an access token for the Graph API if User.Read was in scope. We didn’t request a custom API scope, so the Azure access token might not be useful for our API. Keycloak, on the other hand, typically issues an access token for its own realm by default. For simplicity, we will use the ID token as the bearer token to call our API. (This is not recommended for production – ideally, you’d configure a proper API scope and use the access token intended for the API. But our API will accept any valid JWT from the IdP since we relaxed audience validation.)
    • If the user is using a local Identity account (cookie), we don’t have a JWT at all. In that case, the API call would fail authentication. We’ll focus on the external IdP scenario for the API, as it demonstrates the common pattern of front-end acquiring a token to call a back-end.
  3. Attach token in HttpClient calls: In the Blazor Web project, modify the WeatherApiClient (or wherever the API call is made) to include an Authorization header. One approach is to use the HttpContext in the Blazor server to get the stored token:

    // WeatherApiClient.cs (AspireWorkshopApp.Web)
    public class WeatherApiClient
    {
        private readonly HttpClient _http;
        private readonly IHttpContextAccessor _httpContextAccessor;
        public WeatherApiClient(HttpClient http, IHttpContextAccessor accessor)
        {
            _http = http;
            _httpContextAccessor = accessor;
        }
        public async Task<WeatherForecast[]> GetWeatherAsync()
        {
            // Get access token (or ID token) from the current user
            var token = await _httpContextAccessor.HttpContext.GetTokenAsync("access_token");
            if (token == null)
            {
                token = await _httpContextAccessor.HttpContext.GetTokenAsync("id_token");
            }
            if (token != null)
            {
                _http.DefaultRequestHeaders.Authorization = 
                    new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
            }
            // Call the API
            return await _http.GetFromJsonAsync<WeatherForecast[]>("/weatherforecast");
        }
    }

    Here we inject IHttpContextAccessor to fetch the current user’s tokens. We first try to get an access_token. If the user logged in via Azure AD or Keycloak, and if an access token was issued, this returns it (Configure OpenID Connect Web (UI) authentication in ASP.NET Core | Microsoft Learn). If null, we fallback to the id_token. We then set the Authorization header on the HttpClient to Bearer <token>. Finally, we call the API as before.

    Make sure to register the IHttpContextAccessor (we did in Step 2) and update the DI registration of WeatherApiClient to include the accessor. For example, when adding HttpClient in Program.cs:

    builder.Services.AddHttpClient<WeatherApiClient>(client => {
        client.BaseAddress = new Uri("https+http://apiservice"); 
    });
    // After AddHttpClient, also ensure IHttpContextAccessor is added (already done in Step 2).

    With this setup, each time GetWeatherAsync is called, it will attach the user’s token.

  4. Test the secured API call:

    • First, log in as an external IdP user (Keycloak or Azure).
    • Then navigate to the Weather page. The Blazor component will invoke WeatherApiClient.GetWeatherAsync(). The HttpClient will include the bearer token. On the API side, the JWT Bearer authentication will validate the token’s signature and issuer. Because we turned off audience validation, it will accept the token if it’s from our IdP. The [Authorize] on the endpoint then allows the request through (user is authenticated) and returns data.
    • If the token was missing or invalid, the API would return 401. You can experiment by logging in with a local account (which has no JWT) and see that the Weather call now fails (perhaps no data shows or you get an error). That’s expected because our API only trusts JWTs from the IdPs. For local accounts, one could generate JWTs (for example, by using IdentityServer or JWT tokens from Identity) – but that’s beyond this workshop’s scope.

    You can check the network calls in the browser’s dev tools: the request to /weatherforecast should include an Authorization: Bearer <token> header now, and the response should be 200 OK if authenticated.

    At this stage, our backend API is protected: only logged-in users (with appropriate token) can retrieve data. We have effectively implemented authentication end-to-end: the front-end authenticates the user (via cookie after OIDC), and the back-end verifies the token.

Step 7: Role-Based Authorization (Enforce Roles/Claims on Pages and APIs)

Concept: Beyond just being logged in, we often restrict functionality to certain users based on roles or claims (e.g., “Admin”, “Manager”, etc.). ASP.NET Core Identity supports roles out-of-the-box, and OIDC tokens can carry role/claim information from external IdPs. In this step, we’ll define a role and require it for a specific page and API endpoint.

  1. Create roles in the Identity database: Since we added .AddRoles<IdentityRole>() in Step 3, our Identity system can handle roles. Let’s create an Admin role and assign it to a user.

    • One way is to use a seeding approach in Program.cs. For demo purposes, we can create the role on startup:

      // Program.cs (at the end of building the app, before app.Run()):
      using (var scope = app.Services.CreateScope())
      {
          var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
          if (!await roleManager.RoleExistsAsync("Admin"))
          {
              await roleManager.CreateAsync(new IdentityRole("Admin"));
          }
          var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
          var adminUser = await userManager.FindByEmailAsync("<your email>");
          if (adminUser != null && !await userManager.IsInRoleAsync(adminUser, "Admin"))
          {
              await userManager.AddToRoleAsync(adminUser, "Admin");
          }
      }

      Replace <your email> with the email of a user you want to make an admin (could be the one you registered in Step 3). This code ensures the "Admin" role exists and adds the specified user to that role.

      Alternatively: You can use the Identity UI or EF tools to set a role manually. For brevity, code seeding is shown.

  2. Restrict a page to a role: Let’s create an Admin page that only admins can access. Add a new Razor Component Admin.razor in the Blazor project:

    @page "/admin"
    @attribute [Authorize(Roles="Admin")]
    <h3>Admin Console</h3>
    <p>You are an administrator and can see this page.</p>

    Also add a link to this page in the NavMenu or elsewhere, perhaps visible only to admins:

    <AuthorizeView Roles="Admin">
        <Authorized>
            <NavLink href="admin">Admin Console</NavLink>
        </Authorized>
    </AuthorizeView>

    Now only users in the "Admin" role will navigate to /admin successfully. If a non-admin tries, they’ll get a 403 Forbidden (Identity will show an access denied page or just nothing since we didn't define one, resulting in a blank).

  3. Restrict an API to a role/claim: You can also apply role requirements on APIs. For instance, if we want only admins to get the full weather list, we could do:

    app.MapGet("/weatherforecast/admin", () => /*...*/)
       .RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });

    And/or use [Authorize(Roles="Admin")] on a Controller action if using controllers.

    However, note that with external JWTs (Keycloak/Azure), role claims need to be present in the token and recognized. Our Azure AD integration by default doesn’t include roles unless we define App roles and assign them to users or groups, and Keycloak will include roles if we map them. For simplicity, the local Identity roles will apply when the user is logged in via Identity (which happens for both local and external accounts in our app). Since we linked external logins to Identity, a Keycloak/Azure user can also have local roles if you assign them (the user created after external login in Identity can be added to roles like any other IdentityUser).

  4. Test role-based access: Restart the app to run the seeding (if you added it). Log in with the admin-designated account. You should see the Admin Console link in the menu (because your user is in Admin role). Clicking it shows the page. If you log in with a different user (not in Admin role), you shouldn’t see the link, and even if you navigate to /admin, you’ll be blocked. On the API side, if you protected any endpoint with roles, test calling it (perhaps via the browser or a tool like curl) with an admin’s token vs a non-admin’s token to see the difference (optional).

    Internals: Identity’s cookie for an admin user will contain their roles as claims (the cookie is issued with role claims). When using JWT from external providers, the role claims can be mapped and stored as well. In our config, we set TokenValidationParameters.RoleClaimType = "roles" for Keycloak and Azure. If those tokens had a "roles" claim, the JWT middleware would map that to ClaimsPrincipal.IsInRole. Azure AD tokens can contain roles if we define app roles or use directory roles. Keycloak tokens can include realm roles under a "roles" claim if configured. In this workshop, we primarily demonstrate local roles for simplicity.

Step 8: Policy-Based Authorization (Custom Requirements)

Concept: ASP.NET Core’s policy-based authorization allows defining complex rules beyond just roles. We can require the presence of specific claims or write custom logic in an authorization handler (Introduction to Identity on ASP.NET Core | Microsoft Learn). We’ll create a simple policy as an example (e.g., require an email from a certain domain) and apply it.

  1. Define a custom policy: In Program.cs (Web project), after adding Authorization services, add a policy:

    builder.Services.AddAuthorization(options => {
        options.AddPolicy("RequireContosoEmail", policy => 
            policy.RequireAssertion(context => {
                var emailClaim = context.User.FindFirst(c => c.Type == "email");
                return emailClaim != null && emailClaim.Value.EndsWith("@contoso.com");
            }));
    });

    This policy, "RequireContosoEmail", will pass only if the authenticated user has an email claim ending with “@contoso.com”. We use RequireAssertion with a lambda to implement this custom check. (In a real app, you might use RequireClaim("email", "...") or a full custom handler class, but this inline assertion is convenient for demonstration.)

  2. Protect functionality with the policy: For example, suppose we only want users from Contoso to access the weather page (just as a quirky requirement). We can combine policy with roles as needed, but let’s apply this policy separately. In Weather.razor, change the authorize attribute to:

    @attribute [Authorize(Policy = "RequireContosoEmail")]

    This means only logged-in users whose email ends with @contoso.com will be authorized. Alternatively, create a separate page or button that uses this policy.

    On an API endpoint, you could do similarly: .RequireAuthorization("RequireContosoEmail") or use [Authorize(Policy="RequireContosoEmail")] on a controller action.

  3. Test the custom policy: Restart the app. Try accessing the weather page with different users:

    • If your Identity user’s email or external login email ends with @contoso.com, you should be allowed in. (For example, you could edit one of your user’s email to [email protected] in the database for testing, or create a Keycloak user with that email.)
    • If not, you’ll get a 403 Forbidden for the Weather page. By default, the Blazor navigation might just not show the page or it will be blank if directly accessed, since the authorization failed.

    This demonstrates how to enforce custom business rules using policies. Policies can also require multiple roles, combine requirements (using policy.RequireRole(...).RequireClaim(...) etc.), and call custom authorization handlers for advanced scenarios.

    Note: Our policy relies on an "email" claim. Both Identity and our OIDC providers provide an email claim (Identity uses the email as username by default; Keycloak and Azure include email if requested and available). If a provider didn’t supply an email, this check would fail. In general, ensure the claim you require is issued. We requested the "email" scope for OIDC, which should suffice (Configure OpenID Connect Web (UI) authentication in ASP.NET Core | Microsoft Learn).


Conclusion & Next Steps

Through this workshop, we built a secure application step-by-step:

  • Started with the base .NET Aspire multi-project app (Blazor Server + Web API).
  • Added cookie authentication and protected pages.
  • Integrated ASP.NET Core Identity for robust user management (register/login with a local database) (Introduction to Identity on ASP.NET Core | Microsoft Learn).
  • Displayed user identity in the UI and implemented logout.
  • Configured external OIDC providers (Keycloak and Azure AD), learning how our app redirects to an IdP and handles the callback tokens.
  • Secured the Web API by requiring JWT bearer tokens and modified the Blazor server to send tokens with API calls.
  • Used roles to restrict features to certain users, and policies for custom authorization rules.

Each layer built on the previous ones, illustrating concepts like claims, tokens, cookies, and middleware in practice. By completing these exercises, you’ve seen how authentication flows from the front-end to back-end in ASP.NET Core, and how to enforce authorization rules at both the page and API level.

Where to go from here:

By following this workshop, you (and the author 😄) should now have a much clearer understanding of authentication and authorization in ASP.NET Core – from cookies and Identity to JWTs and external providers – and how these pieces fit together in a modern Blazor + Web API architecture. Happy coding!

Sources:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment