1 Introduction: The Modern Security Imperative
The software landscape has transformed dramatically in the last decade. Where we once built monolithic web applications running on a single server, today’s systems are sprawling constellations of microservices, APIs, single-page applications (SPAs), and mobile clients. As architects, we are tasked not only with delivering features and performance but also with protecting users, data, and business value across this complex ecosystem.
1.1 The Shift in Application Architecture: From Monoliths to Distributed Systems
Think back to the early days of the web. Most applications were monolithic: all functionality lived on a single codebase and was served from one web server. User sessions and security were handled by that server. When a user logged in, a session cookie kept track of their identity and permissions. The model was simple and, for its time, effective.
Fast-forward to the present. Modern applications are fragmented by design. We now build:
- Microservices: Small, autonomous services responsible for a single domain or feature. These may be written in different languages and hosted on different servers.
- Single-Page Applications (SPAs): Frontend frameworks like Angular, React, and Vue.js that run in the browser, making API calls to backends.
- Mobile Apps: Native or cross-platform apps that talk to APIs over HTTP.
- Cloud-hosted APIs: Consumed by partners, other services, or public clients.
This distributed architecture brings immense benefits in terms of scalability and agility. However, it also introduces new security challenges. How do you authenticate and authorize users (and services) consistently across so many endpoints and clients? How do you avoid “session sprawl” or leaking credentials between services?
1.2 The Problem with Traditional Security: Why Session-Based Authentication Falls Short
Let’s examine the old model: a user logs in to a website, the server authenticates them, and it sets a session cookie. The browser sends this cookie with every subsequent request, so the server knows who the user is.
While effective for traditional web apps, this model quickly breaks down in distributed systems:
- Stateless APIs: RESTful APIs should not depend on server-side session state.
- Multiple Clients: SPAs and mobile apps cannot easily share cookies with APIs due to cross-origin restrictions.
- Scalability: Session state must be shared or replicated across multiple backend servers, complicating scaling and load balancing.
- Service-to-Service Communication: Microservices calling each other cannot use browser cookies.
The old ways simply do not work. Security needs to adapt.
1.3 Introducing the Pillars of Modern Security
To secure modern systems, we need new tools and concepts. At a high level, the three pillars of modern security in distributed applications are:
- Tokens (especially JWTs – JSON Web Tokens): Self-contained, portable representations of user or service identity. Sent with each request, these allow stateless authentication and can be verified by any backend service.
- Protocols (OAuth 2.0 and OpenID Connect): Industry-standard ways to delegate authentication and authorization, allowing APIs and clients to “speak the same language” about identity and permissions.
- Identity Providers: Centralized services (often based on protocols like OIDC) that issue tokens, validate credentials, and act as the single source of truth for identity. Examples include IdentityServer, Auth0, Azure AD, and Okta.
1.4 Article Roadmap: What We Will Build
This article is your deep-dive guide to mastering authentication and authorization in ASP.NET Core using these modern approaches. Here’s our roadmap:
- We’ll start with core concepts: understanding authentication versus authorization in ASP.NET Core, and exploring how claims, roles, and policies are the building blocks of security.
- We’ll quickly review the legacy model—cookie-based authentication—its flow, strengths, and inherent limitations in the modern world.
- The heart of this article will be a comprehensive exploration of token-based security, especially JWTs, OIDC, and how IdentityServer can help you implement centralized authentication for a range of applications, from SPAs to APIs to microservices.
Throughout, you’ll see practical C# code examples leveraging the latest .NET features, architect insights, and guidance for real-world scenarios.
Are you ready to strengthen your architecture’s security backbone? Let’s start by clarifying the two fundamental concepts that every secure application must handle: authentication and authorization.
2 The Bedrock: Authentication (AuthN) vs. Authorization (AuthZ) in ASP.NET Core
At the heart of any secure application are two related, but distinct, concerns: authentication and authorization. As an architect, understanding the difference—and how ASP.NET Core handles each—is essential.
2.1 Authentication: “Who are you?”
Authentication is about identity. When a user or a service presents credentials, authentication answers: “Is this really who they claim to be?”
The Concept of an Identity and a Principal
In .NET, identity is represented by a set of claims. These are statements about the user or service, such as their name, unique ID, email address, or roles.
The framework encapsulates this identity information in the ClaimsPrincipal class, which wraps one or more ClaimsIdentity instances. Each ClaimsIdentity contains a set of Claim objects.
Analogy: Think of ClaimsPrincipal as a person carrying a wallet (ClaimsIdentity) with a collection of ID cards and credentials (Claim).
Dissecting HttpContext.User, ClaimsPrincipal, and ClaimsIdentity
When a request reaches your ASP.NET Core application, authentication middleware attempts to validate the provided credentials (a token, cookie, etc). If successful, it builds a ClaimsPrincipal object and attaches it to HttpContext.User.
// Accessing the authenticated user in a controller
public IActionResult GetProfile()
{
var user = HttpContext.User;
if (user.Identity?.IsAuthenticated == true)
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// Further logic
}
}
Here’s what’s happening:
HttpContext.Userholds the authenticated principal for the current request.User.Identityindicates the authentication status and type.- Claims (such as email, role, or custom attributes) are accessible via methods like
FindFirst().
How ASP.NET Core’s Middleware Pipeline Handles Authentication Schemes
ASP.NET Core uses a middleware pipeline to handle authentication. Multiple authentication schemes can be registered—each corresponding to a different credential format (cookies, JWTs, etc). The pipeline tries each scheme according to configuration, attempting to build the ClaimsPrincipal.
A basic setup in Program.cs might look like this:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
});
This configures the app to expect JWT Bearer tokens, issued by a central authority.
You can also support multiple schemes (e.g., JWT for APIs, cookies for the admin portal) and select which one to use per request.
2.2 Authorization: “What are you allowed to do?”
While authentication answers who the user is, authorization answers what they are permitted to do. This distinction is critical in modern architectures, where fine-grained, context-aware access control is required.
Moving Beyond Simple Roles: The Power of Claims-Based Authorization
In the past, authorization was often role-based: users were assigned roles (like “Admin” or “User”), and access was granted or denied based on those roles.
But today’s systems need more flexibility. What if you want to allow access based on department, region, subscription tier, or even time of day? Roles quickly become unwieldy.
ASP.NET Core’s claims-based authorization lets you make decisions based on any attribute included in a claim—not just roles. For instance, you might check for a claim like department=Finance or subscription=Premium.
Architecting for Flexibility: A Deep Dive into Policy-Based Authorization
Policy-based authorization is the modern way to implement flexible, scalable access control in ASP.NET Core. Instead of scattering authorization logic throughout your code, you define reusable “policies” in a central place.
2.2.1 Simple Policies (RequireClaim, RequireRole)
A policy can be as simple as requiring a specific claim or role. You register policies in your Program.cs or Startup.cs:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdminRole", policy => policy.RequireRole("Admin"));
options.AddPolicy("RequireDepartment", policy => policy.RequireClaim("department", "Finance"));
});
You can then use these policies in controllers or endpoints:
[Authorize(Policy = "RequireAdminRole")]
public IActionResult AdminDashboard()
{
// Only accessible to users with the "Admin" role
}
2.2.2 Custom Policies with IAuthorizationRequirement and AuthorizationHandler
Simple policies are powerful, but what if you need more complex logic? For instance, only allow access if the user is a manager and it’s a weekday.
This is where custom authorization requirements and handlers come in.
Step 1: Define the Requirement
public class MustBeManagerRequirement : IAuthorizationRequirement
{
// You can include additional data here if needed
}
Step 2: Implement the Handler
public class MustBeManagerHandler : AuthorizationHandler<MustBeManagerRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MustBeManagerRequirement requirement)
{
var isManager = context.User.HasClaim("position", "Manager");
if (isManager && DateTime.UtcNow.DayOfWeek != DayOfWeek.Saturday && DateTime.UtcNow.DayOfWeek != DayOfWeek.Sunday)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Step 3: Register and Use the Policy
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ManagerOnWeekday", policy =>
policy.Requirements.Add(new MustBeManagerRequirement()));
});
builder.Services.AddSingleton<IAuthorizationHandler, MustBeManagerHandler>();
Now you can use the [Authorize(Policy = "ManagerOnWeekday")] attribute.
2.2.3 Why Architects Should Default to Policy-Based Authorization
Why invest in policy-based authorization, even for “simple” projects?
- Centralized Control: All authorization logic lives in one place, making it easy to review, audit, and update.
- Scalability: As requirements grow, you can extend or combine policies without rewriting controller code.
- Testability: Custom requirements and handlers can be unit-tested independently of the web stack.
- Separation of Concerns: Business logic remains in your services; security logic lives in policies.
By defaulting to policy-based authorization, you future-proof your application and give yourself the tools to adapt to changing requirements—whether that’s supporting new user attributes, integrating with external identity providers, or adding new features.
3 The Legacy Approach: A Quick Look at Cookie-Based Authentication
To understand why modern security architectures move away from cookies, it’s worth taking a brief look at how cookie-based authentication works—and where it still fits.
3.1 How It Works: The Flow of Cookie Authentication with ASP.NET Core Identity
Cookie authentication has long been the default for classic web apps. Here’s the typical flow:
- User Submits Credentials: The user posts a username and password to the login endpoint.
- Credentials Verified: The server validates the credentials (often with ASP.NET Core Identity).
- Cookie Issued: On success, the server creates an authentication ticket, serializes the user’s identity (claims), and sends it back as an encrypted HTTP cookie.
- Subsequent Requests: The browser sends this cookie with each HTTP request, allowing the server to recognize the user.
A Minimal Example
public async Task<IActionResult> Login(LoginViewModel model)
{
var result = await _signInManager.PasswordSignInAsync(model.Username, model.Password, model.RememberMe, false);
if (result.Succeeded)
{
return RedirectToAction("Index", "Home");
}
ModelState.AddModelError("", "Invalid login attempt");
return View(model);
}
Once logged in, the user’s session persists thanks to the cookie.
3.2 The Use Case: Continued Relevance for Traditional, Server-Rendered Web Applications
For server-rendered web apps—such as those built with MVC or Razor Pages—cookie authentication remains a robust and simple solution:
- Browsers handle cookies natively: The user’s identity is managed automatically as they navigate the site.
- Integrated sign-in/out: Forms authentication, anti-forgery tokens, and login/logout flows are all well-supported.
If your application is purely server-rendered, cookie-based authentication is still a sound choice.
3.3 The Architectural Boundaries: Why Cookies Create Challenges for SPAs, Mobile Clients, and Service-to-Service Communication
As applications become more distributed, cookie-based authentication starts to crack under the pressure:
- Single-Page Applications (SPAs): SPAs are often served from a different domain (e.g.,
app.example.com) than the backend API (api.example.com). Cookies are limited by SameSite policies and CORS, complicating authentication across origins. - Mobile Apps: Mobile clients typically don’t handle cookies like browsers do. Passing authentication information requires a different mechanism.
- Service-to-Service Communication: When microservices call each other, there’s no browser to manage cookies. Services need to present credentials directly, often as HTTP headers.
- Scalability: Cookies depend on server-side session state. Scaling out means sharing or centralizing this state, which is hard to manage at large scale.
- Security: Cookies are vulnerable to CSRF attacks, requiring extra mitigation (e.g., anti-forgery tokens).
For these reasons, the industry has shifted towards token-based authentication—especially JWTs—paired with modern protocols like OAuth 2.0 and OpenID Connect.
4 The Game Changer: A Deep Dive into JSON Web Tokens (JWTs)
If you’ve spent any time building modern APIs, you’ve almost certainly encountered the term “JWT.” But what makes JWTs such a central piece of today’s authentication and authorization story? To understand their value, let’s start with first principles.
4.1 What Is a JWT? The Concept of a Self-Contained, Stateless Token
At its core, a JSON Web Token (JWT) is a compact, URL-safe string that securely transmits information between parties. The core idea behind JWT is statelessness—once issued, the token itself carries all the information a resource server needs to identify and authorize the client. There’s no need for the API to look up session data in a database or cache. This statelessness is a fundamental shift from traditional session cookies.
Think of a JWT as a digitally signed “boarding pass.” Once issued, it’s valid for a set period and carries all the data needed for downstream systems to trust and act upon it—provided the signature checks out.
Practical Example: Why Stateless Matters
Imagine a microservices-based e-commerce platform. The authentication service issues a JWT when a user logs in. The order, payment, and notification services can all independently verify and process the JWT—no need for each service to call back to a central “who is this user?” database. This is not just a matter of efficiency; it’s a building block for scalable, reliable systems.
4.2 Anatomy of a JWT
To demystify JWTs, let’s break down their structure. Every JWT consists of three distinct parts, separated by dots (.):
- Header
- Payload
- Signature
The result is a string that looks something like this:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
4.2.1 Header: Algorithm (alg) and Token Type (typ)
The header is a simple JSON object describing the metadata about the token:
{
"alg": "RS256",
"typ": "JWT"
}
- alg: The signing algorithm used, such as
HS256(HMAC with SHA-256) orRS256(RSA with SHA-256). - typ: Indicates the token type, almost always “JWT”.
This part is Base64Url-encoded and forms the first segment of the JWT.
4.2.2 Payload: The Claims – Registered, Public, and Private
The payload carries the “claims,” which are statements about an entity (usually the user) and additional data. Claims fall into three categories:
-
Registered claims: Defined by the JWT specification for interoperability. Examples include:
iss(issuer): Who issued the tokensub(subject): The unique identifier of the useraud(audience): Intended recipient of the token (e.g., an API)exp(expiration time): When the token expiresiat(issued at): When the token was issuednbf(not before): When the token becomes valid
-
Public claims: Agreed upon by the application community but not registered in the JWT standard.
-
Private claims: Custom claims agreed between parties, such as
role,email, ortenant_id.
A sample payload might look like this:
{
"sub": "248289761001",
"name": "Jane Doe",
"email": "jane.doe@contoso.com",
"role": "Admin",
"iat": 1710000000,
"exp": 1710003600
}
This section, too, is Base64Url-encoded.
4.2.3 Signature: Ensuring Integrity and Authenticity
The third part is the signature. It’s computed by signing the Base64Url-encoded header and payload using the algorithm specified in the header and a secret (for HMAC) or a private key (for RSA/ECDSA):
signature = sign(encode(header) + '.' + encode(payload), key)
This signature allows the recipient to verify that the token has not been tampered with and, for asymmetric keys, that it truly comes from the trusted issuer.
4.3 Symmetric (HMAC) vs. Asymmetric (RSA/ECDSA) Signatures: An Architectural Decision
Security is always about trade-offs. When choosing how to sign JWTs, architects must consider the pros and cons of symmetric (shared secret) versus asymmetric (public/private key) algorithms.
Symmetric (HMAC) Signatures
How it works: The same key is used to both sign and verify the JWT.
- Pros: Simpler to implement, slightly faster performance.
- Cons: Key must be securely distributed and shared among all verifying parties. If the key leaks, any party can forge tokens.
Best for: Simpler architectures with a single trusted backend, or where all services can securely share a secret.
Asymmetric (RSA/ECDSA) Signatures
How it works: A private key is used to sign the JWT. Any party with the public key can verify the signature, but only the issuer (with the private key) can create valid tokens.
- Pros: Only the issuer needs the private key; downstream services only need the public key. Public keys can be widely distributed, supporting secure, scalable verification.
- Cons: Slightly more complex to manage, but tooling is mature.
Best for: Microservices, distributed systems, or any environment where multiple independent services need to verify tokens.
Implications for Microservice Architectures
With microservices, asymmetric signatures are generally preferred. Distributing a public key is safer and less complex than managing a shared secret across many teams and deployments. Most modern identity providers—including IdentityServer, Azure AD, and Auth0—default to RS256 or similar algorithms for this reason.
4.4 Implementing JWT Bearer Authentication in an ASP.NET Core API
Let’s see what this looks like in a practical, production-ready .NET 8+ API.
Configuring the AddJwtBearer Authentication Handler
You configure JWT authentication in your service registration. Here’s how you might set it up for a system where JWTs are issued by an external identity provider:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = "https://idp.contoso.com"; // The Identity Provider's base URL
options.Audience = "api.contoso.com"; // The expected audience claim
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://idp.contoso.com",
ValidateAudience = true,
ValidAudience = "api.contoso.com",
ValidateLifetime = true,
ValidateIssuerSigningKey = true
};
});
A few things are happening here:
- The middleware will automatically fetch the issuer’s public keys (via a
.well-known/openid-configurationendpoint if OIDC-compliant). - All incoming requests are checked for a valid JWT in the
Authorization: Bearerheader. - The claims from the token populate
HttpContext.User.
Validating Issuer, Audience, and Lifetime
This setup ensures that:
- The issuer (
iss) claim matches your trusted authority. - The audience (
aud) claim ensures the token was meant for this API. - The lifetime (
exp,nbf) ensures expired or not-yet-valid tokens are rejected. - The signature is validated against the identity provider’s public key.
Sample: Securing a Controller
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult GetOrders()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// Business logic here
return Ok();
}
}
No need to manually parse or validate tokens—the middleware handles this securely.
4.5 Critical Security Considerations for JWTs
JWTs are powerful, but with power comes responsibility. They are bearer tokens: whoever possesses a valid JWT can access protected resources. That makes handling and validating them correctly critical.
Token Theft (XSS) and Secure Storage
A common pitfall, especially in SPAs, is where to store JWTs on the client.
- Never store JWTs in
localStorageorsessionStorage: JavaScript-accessible storage is vulnerable to XSS attacks. If an attacker injects code, they can steal the token. - HttpOnly Cookies: For browser-based apps, using secure, HttpOnly, SameSite cookies is safer. HttpOnly means JavaScript cannot read the token, mitigating XSS risk.
- Mobile Apps: Use the platform’s secure storage (e.g., Keychain, Android Keystore).
Architectural Insight: If you need your SPA to call APIs with JWTs, consider a backend-for-frontend (BFF) pattern that keeps tokens on the server.
Replay Attacks and the jti (JWT ID) Claim
Since JWTs are self-contained, an attacker who obtains a token can replay it until it expires. To mitigate:
- Use short-lived access tokens (minutes, not hours).
- Issue a
jticlaim (JWT ID) and maintain a server-side blacklist for revoked tokens (e.g., on logout). - For high-security operations, consider requiring additional proof (e.g., confirmation codes).
The Importance of https and Preventing Algorithm-Switching Attacks
- Always serve JWTs over HTTPS. Never allow tokens to be sent over unencrypted connections.
- Explicitly configure allowed algorithms: Some attacks exploit misconfigured JWT libraries that accept “none” or a weak algorithm in the header. Always whitelist acceptable algorithms in your API configuration.
Summary Table: JWT Security Checklist
| Concern | Mitigation |
|---|---|
| Token theft (XSS) | HttpOnly cookies, never localStorage/sessionStorage |
| Replay attacks | Short-lived tokens, jti + blacklist, rotate tokens |
| Signature spoofing | Enforce allowed algorithms, always verify signature |
| Network interception | HTTPS everywhere |
5 The Rulebook: Standardizing Communication with OAuth 2.0 and OpenID Connect (OIDC)
JWTs solve the format and statelessness problems. But how do all parties agree on how to authenticate users, issue tokens, and authorize access? Enter OAuth 2.0 and OpenID Connect (OIDC)—the industry’s rulebooks for secure, standardized delegation of authentication and authorization.
5.1 Clarifying the Confusion: JWT Is a Token Format, OIDC/OAuth 2.0 Are Protocols
It’s easy to conflate JWTs with protocols like OAuth 2.0 or OpenID Connect, but they are not interchangeable concepts:
- JWT: A specific way to encode data into a token—a format.
- OAuth 2.0: A protocol for delegated authorization. It defines “who does what, when, and how” in exchanging credentials for tokens.
- OpenID Connect (OIDC): An identity layer on top of OAuth 2.0 that standardizes authentication.
Many protocols (including OAuth 2.0/OIDC) use JWTs as their access tokens or ID tokens—but they could use other formats.
Analogy: If JWT is the envelope, OAuth 2.0 is the postal service protocol, and OIDC adds a notarized stamp verifying identity.
5.2 OAuth 2.0: The Authorization Framework
At its heart, OAuth 2.0 is a protocol for granting limited access to resources on behalf of a user or service. It’s designed to let users authorize applications to access their data without sharing credentials.
Core Roles
- Resource Owner: The user or system owning the data (e.g., an end user).
- Client: The app requesting access (could be a SPA, mobile app, or backend service).
- Authorization Server: Issues tokens after authenticating the resource owner (e.g., IdentityServer, Azure AD).
- Resource Server: The API being accessed, which validates and processes tokens.
Each role has a specific place in the protocol. Understanding these distinctions is crucial for architects.
Choosing the Right Flow (Grant Type) for Your Architecture
OAuth 2.0 defines several “grant types”—ways in which a client can obtain an access token.
5.2.1 Authorization Code Flow + PKCE: The Modern Standard for Web and Mobile Apps
Authorization Code Flow is the gold standard for confidential clients (apps running on a server). However, for public clients (SPAs, mobile apps), it must be paired with PKCE (Proof Key for Code Exchange) for security.
Why PKCE? PKCE mitigates the risk of an attacker intercepting the authorization code by requiring a one-time “code verifier” known only to the client.
Flow Summary:
- The client app generates a code verifier and a code challenge (a hashed version).
- User is redirected to the authorization server’s login page, with the code challenge.
- After login, the authorization server redirects the user back to the client with an authorization code.
- The client exchanges the code and the original code verifier for tokens.
- The server issues an access token (and usually an ID token).
Why is this better than the old “implicit flow”? Because the tokens are not exposed in the browser URL and can be bound to the client using PKCE.
5.2.2 Client Credentials Flow: For Trusted, Non-Interactive Service-to-Service Communication
Some scenarios involve no user at all—just two services communicating. The client credentials flow allows a backend service to request a token directly, using its own credentials.
Typical Use Case: Microservice A needs to call Microservice B.
- Service A authenticates to the authorization server with its own client credentials (ID and secret).
- The authorization server issues an access token scoped to what Service A is allowed to do.
- Service A uses this token to call Service B.
No user interaction is involved—pure machine-to-machine.
5.2.3 Why the Implicit Flow Is No Longer Recommended
The implicit flow was once the go-to for browser-based apps, but it’s now deprecated due to security vulnerabilities:
- Tokens are exposed in the browser’s address bar and history.
- No support for refresh tokens, leading to worse UX.
- PKCE-based authorization code flow is just as convenient, and far more secure.
Modern best practice: Always use Authorization Code Flow with PKCE for all browser and mobile apps.
5.3 OpenID Connect (OIDC): The Authentication Layer on Top
OAuth 2.0 provides a robust framework for authorization (who can do what), but it’s silent on authentication (who is this user). That’s where OpenID Connect (OIDC) comes in.
What OIDC Adds
OIDC is a thin identity layer built on top of OAuth 2.0. It adds:
- ID Token: A JWT containing authenticated user information—who the user is, when they authenticated, and with what method.
- UserInfo Endpoint: A standardized API for clients to fetch additional user attributes.
- Standard Scopes: Such as
openid(required),profile,email, which specify what information the client is requesting.
This means any client or service speaking OIDC can reliably ask, “Who is this user?” and trust the answer.
How OIDC Provides Verifiable User Identity
When a client requests authentication, the authorization server issues an ID token (alongside any access token). The ID token is intended for the client and includes:
- The user’s unique identifier (
subclaim) - When and how the user authenticated
- Optional claims, like name, email, etc.
- A digital signature to guarantee authenticity
Important Distinction: The access_token is for APIs (resource servers), allowing access to resources. The id_token is for the client, to establish the user’s identity.
5.4 The Full Flow: Step-by-Step Walkthrough of the OIDC Authorization Code Flow
Let’s demystify what really happens when a user logs into your modern app using OIDC.
Step 1: Client Initiates Login
Your application redirects the user’s browser to the Identity Provider’s authorization endpoint. It sends:
- Client ID (identifies your app)
- Requested scopes (
openid,profile,email) - Redirect URI (where the user should be sent after login)
- State (to mitigate CSRF attacks)
- Code challenge (for PKCE)
- Nonce (to mitigate replay attacks)
Example URL:
https://idp.contoso.com/connect/authorize?
response_type=code
&client_id=webapp1
&redirect_uri=https://webapp1.contoso.com/callback
&scope=openid profile email
&state=af0ifjsldkj
&code_challenge=3q2+7w==
&code_challenge_method=S256
&nonce=xyzabc
Step 2: User Authenticates
The user interacts with the Identity Provider’s login page, provides credentials, and (optionally) completes MFA.
Step 3: Authorization Server Issues Authorization Code
If authentication succeeds, the Identity Provider redirects the user’s browser back to the specified redirect_uri with an authorization code and the original state value.
https://webapp1.contoso.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
Step 4: Client Exchanges Code for Tokens
The client’s backend (not the browser) calls the Identity Provider’s token endpoint. It sends:
- The authorization code received
- The original code verifier (from PKCE)
- Its client ID and secret (if confidential client)
- Redirect URI
The Identity Provider validates everything and issues:
-
ID token (for authentication)
-
Access token (for API calls)
-
(Optionally) a refresh token (to get new tokens without re-authenticating)
Step 5: Tokens Are Used
- The ID token is used by the client to establish user identity and create a session.
- The access token is sent with API calls (usually in the
Authorization: Bearer ...header) to authenticate and authorize requests.
Step 6: APIs Validate the Access Token
APIs verify the access token’s signature, issuer, audience, and expiration. They then grant or deny access based on the token’s claims.
Step 7: UserInfo Endpoint (Optional)
The client can request additional user information (if allowed) from the Identity Provider’s UserInfo endpoint, authenticated with the access token.
Diagram (Text-Based): OIDC Authorization Code Flow
[Browser] --(1: authorize request)--> [Identity Provider]
[Identity Provider] --(2: login form)--> [Browser]
[Browser] --(3: credentials)--> [Identity Provider]
[Identity Provider] --(4: auth code + state)--> [Browser]
[Browser] --(5: redirect to app with code)--> [Client Backend]
[Client Backend] --(6: code + verifier)--> [Identity Provider]
[Identity Provider] --(7: tokens)--> [Client Backend]
[Client Backend] --(8: access token)--> [API]
[API] --(9: validate token)--> [Identity Provider's public keys]
6 The Central Nervous System: Implementing Duende IdentityServer
As distributed systems grow in scale and complexity, their security posture must evolve in kind. A robust, centralized Identity Provider (IdP) forms the “central nervous system” for all authentication and authorization decisions, serving as the single source of truth across applications and APIs.
6.1 The Case for a Centralized Identity Provider (IdP)
Why architect your landscape around a centralized IdP like Duende IdentityServer?
Benefits
- Single Sign-On (SSO): Users authenticate once and gain access to all participating applications without repeated logins.
- Simplified Client Configuration: Centralized client and resource definitions reduce duplicated configuration and drift.
- Centralized Policy Management: Security policies—like password strength, MFA, and user lockouts—are enforced uniformly, reducing risk.
- Enhanced Security: Attack surfaces are reduced; sensitive credentials are only handled by the IdP, not scattered across services.
- Easy Compliance and Auditing: Access patterns and login events are monitored in one place, streamlining regulatory compliance.
- Future-Proofing: Adding new clients, protocols, or integrations becomes an exercise in configuration, not custom development.
A Note on Evolution: IdentityServer4 to Duende IdentityServer
For years, IdentityServer4 (open-source) was the de facto standard in the .NET world. In 2021, its maintainers transitioned to Duende IdentityServer. It’s now a commercial offering but remains free for many small-scale uses and is fully open-source. If you’re starting a new project, Duende is the way forward; for legacy applications, a migration path is available.
Architectural Insight: IdentityServer isn’t just for user authentication. It’s equally vital for securing service-to-service APIs and non-human clients, forming a foundation for Zero Trust security models.
6.2 Building Your IdentityServer: The Initial Setup
Let’s walk through creating a robust Duende IdentityServer project.
Project Creation and Essential NuGet Packages
Start with a new ASP.NET Core Web Application. In .NET 8 and above, create an Empty or Web template. Then, add these NuGet packages:
dotnet add package Duende.IdentityServer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Duende.IdentityServer.AspNetIdentity
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
- Duende.IdentityServer: The heart of your IdP.
- AspNetIdentity: Integrates ASP.NET Core Identity as your user store.
- EF Core: For persisting user data and, optionally, configuration.
The Program.cs/Startup.cs Configuration
In .NET 8+, configuration generally lives in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Add ASP.NET Core Identity with EF Core
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
// Configure IdentityServer
builder.Services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseSuccessEvents = true;
})
.AddAspNetIdentity<ApplicationUser>()
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryClients(Config.Clients)
.AddDeveloperSigningCredential(); // Use real certs in production
var app = builder.Build();
app.UseRouting();
app.UseIdentityServer();
app.MapDefaultControllerRoute();
app.Run();
This setup does a few things:
- Configures ASP.NET Core Identity to store users in a SQL Server database.
- Integrates IdentityServer with ASP.NET Core Identity, so your users are managed centrally.
- Loads clients, scopes, and resources from in-memory definitions (for demo—can move to EF for production).
- Adds a signing credential for tokens (swap for a real certificate in production).
6.3 Defining the Security Landscape: Core IdentityServer Resources
A robust IdentityServer configuration is defined by four core resource types.
6.3.1 Clients
A Client is any application that wants to interact with your IdentityServer—this could be a web app, SPA, mobile app, or machine service.
Sample client definition:
public static IEnumerable<Client> Clients =>
new List<Client>
{
new Client
{
ClientId = "mvc",
ClientName = "MVC Web App",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RedirectUris = { "https://localhost:5002/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" },
AllowedScopes = { "openid", "profile", "api.read" },
ClientSecrets = { new Secret("supersecret".Sha256()) }
},
new Client
{
ClientId = "worker",
ClientName = "Background Worker",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("workersecret".Sha256()) },
AllowedScopes = { "api.read" }
}
};
Note:
- Each client is assigned allowed grant types, redirect URIs, secrets, and permitted scopes.
- SPA clients will require extra configuration (e.g.,
AllowedCorsOrigins,RequireClientSecret = false).
6.3.2 Identity Resources
These define which user attributes (claims) can be included in the ID token.
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(), // Required
new IdentityResources.Profile(), // Standard profile claims
new IdentityResources.Email() // Email claims
};
Architectural tip: For privacy, always scope down identity resources to only what clients truly need.
6.3.3 API Scopes
Scopes represent specific permissions your APIs expose. Clients must request scopes, and users must consent to them.
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("api.read", "Read Access to API"),
new ApiScope("api.write", "Write Access to API")
};
Granular scopes enable fine-grained control (think: least privilege).
6.3.4 API Resources
API Resources group scopes and define what audiences are valid.
public static IEnumerable<ApiResource> ApiResources =>
new ApiResource[]
{
new ApiResource("myapi", "My Protected API")
{
Scopes = { "api.read", "api.write" },
ApiSecrets = { new Secret("apisecret".Sha256()) },
UserClaims = { "role", "email" }
}
};
- The
ApiResource’s name is the value for theaudclaim. - Claims listed here can be included in access tokens issued for this API.
Summary Table: IdentityServer Resource Types
| Resource Type | Purpose | Example |
|---|---|---|
| Client | Application needing tokens | MVC app, worker |
| Identity Resource | User identity claims available in ID token | openid, email |
| API Scope | Granular API permissions | api.read |
| API Resource | A logical API, with associated scopes and audience | myapi |
6.4 Integrating a User Store: Using ASP.NET Core Identity
The last piece is to connect your IdP to a user store. ASP.NET Core Identity provides a mature, extensible system for handling:
- User registration and password management
- External logins (Google, Microsoft, etc.)
- Password reset, lockout, and security stamps
- Two-factor authentication
Integration is seamless: IdentityServer “delegates” to ASP.NET Core Identity for everything user- and credential-related. Users log in via standard ASP.NET Core Identity pages, and IdentityServer issues tokens post-authentication.
Extending the User Model:
Customize the ApplicationUser class to include business-specific properties (e.g., Department, SubscriptionLevel). Claims mapping logic can then surface these attributes to tokens as needed.
7 A Practical, Real-World Implementation
Let’s tie all these concepts together by securing a real-world architecture: a protected Web API, a traditional server-side web application, and a background worker service—all federated via IdentityServer.
7.1 The Scenario
Imagine a system where:
- Web API: Exposes endpoints for CRUD operations on business data.
- MVC/Razor Pages Web App: Provides a full-featured UI for employees, consuming the API.
- Background Worker Service: Processes scheduled jobs or integrations, needing machine-to-machine API access.
All applications should delegate authentication to IdentityServer, use JWTs for stateless authorization, and enforce least-privilege access.
7.2 Part 1: The Protected Web API
Configuring AddJwtBearer to Trust IdentityServer
In your Web API project, wire up JWT Bearer authentication to trust your IdentityServer instance.
Example in Program.cs:
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://localhost:5001"; // URL of IdentityServer
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false // We'll check scopes instead
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanReadData", policy =>
policy.RequireClaim("scope", "api.read"));
});
- The
Authorityis the base address of your IdP. - Setting
ValidateAudience = falselets you handle audience via scopes (typical in microservices). - Policies enforce fine-grained access at the endpoint level.
Using Policy-Based Authorization
Now secure your endpoints with policies:
[ApiController]
[Route("api/[controller]")]
public class DataController : ControllerBase
{
[HttpGet]
[Authorize(Policy = "CanReadData")]
public IActionResult Get()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// Return data for the authenticated user
return Ok(new { Message = $"Hello user {userId}" });
}
}
Note:
- The access token’s
scopeclaim must includeapi.readfor access to succeed. - If the token is missing or invalid, the user receives a
401 Unauthorized.
7.3 Part 2: The Client MVC/Razor Pages Web App
Configuring the AddOpenIdConnect Handler
Your web app must authenticate users via OIDC, federating to IdentityServer.
Example Program.cs setup:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://localhost:5001";
options.ClientId = "mvc";
options.ClientSecret = "supersecret";
options.ResponseType = "code";
options.SaveTokens = true; // Store tokens in authentication session
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("api.read"); // Needed for API access
options.Scope.Add("offline_access"); // For refresh tokens
});
Handling Login, Logout, and Token Acquisition
- Login: Users are automatically redirected to IdentityServer for authentication if unauthenticated.
- Logout: Use
SignOut("Cookies", "OpenIdConnect")to trigger federated sign-out.
Managing Tokens and Calling the Protected API
Tokens are automatically persisted in the authentication session (if SaveTokens = true). To call the protected API, extract the access token and inject it into HTTP requests.
Example using IHttpClientFactory:
public class ApiService
{
private readonly IHttpClientFactory _clientFactory;
private readonly IHttpContextAccessor _contextAccessor;
public ApiService(IHttpClientFactory clientFactory, IHttpContextAccessor contextAccessor)
{
_clientFactory = clientFactory;
_contextAccessor = contextAccessor;
}
public async Task<string> GetDataAsync()
{
var accessToken = await _contextAccessor.HttpContext.GetTokenAsync("access_token");
var client = _clientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.GetAsync("https://localhost:5003/api/data");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
Register dependencies in Program.cs:
builder.Services.AddHttpClient();
builder.Services.AddHttpContextAccessor();
Handling Token Expiration and Using Refresh Tokens
OIDC enables long-lived sessions via refresh tokens. When the access token expires, your app can silently obtain a new token without user intervention.
- Request the
offline_accessscope to receive a refresh token. - Use a library (like IdentityModel.AspNetCore) to manage token refresh automatically with HTTP clients.
Token renewal is critical for robust, seamless user experiences.
7.4 Part 3: The Client Worker Service (Service-to-Service)
Automated background services or microservices often need direct access to protected APIs, independent of user sessions. Here’s how to set that up using the Client Credentials flow.
Implementing the Client Credentials Flow
The Worker registers as a client in IdentityServer with GrantTypes.ClientCredentials.
Token request logic:
public class TokenService
{
private readonly IHttpClientFactory _httpClientFactory;
public TokenService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> GetAccessTokenAsync()
{
var client = _httpClientFactory.CreateClient();
var discovery = await client.GetDiscoveryDocumentAsync("https://localhost:5001");
if (discovery.IsError) throw new Exception(discovery.Error);
var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = discovery.TokenEndpoint,
ClientId = "worker",
ClientSecret = "workersecret",
Scope = "api.read"
});
if (tokenResponse.IsError) throw new Exception(tokenResponse.Error);
return tokenResponse.AccessToken;
}
}
Use this token when calling the API:
public class ApiClient
{
private readonly IHttpClientFactory _clientFactory;
private readonly TokenService _tokenService;
public ApiClient(IHttpClientFactory clientFactory, TokenService tokenService)
{
_clientFactory = clientFactory;
_tokenService = tokenService;
}
public async Task CallApiAsync()
{
var token = await _tokenService.GetAccessTokenAsync();
var client = _clientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync("https://localhost:5003/api/data");
// Handle response...
}
}
Automating Token Management
Manually handling token lifetimes is error-prone. Libraries like IdentityModel.AspNetCore provide ready-made handlers to manage token acquisition and caching for background services.
Example service registration:
builder.Services.AddAccessTokenManagement();
// Registers an HttpClient that automatically acquires and refreshes access tokens
builder.Services.AddClientAccessTokenHttpClient("api.client", configureClient: client =>
{
client.BaseAddress = new Uri("https://localhost:5003/");
});
Usage:
public class WorkerService
{
private readonly IHttpClientFactory _clientFactory;
public WorkerService(IHttpClientFactory clientFactory)
{
_clientFactory = clientFactory;
}
public async Task ExecuteAsync()
{
var client = _clientFactory.CreateClient("api.client");
var response = await client.GetAsync("api/data");
// Handle response...
}
}
This pattern abstracts token management, reduces errors, and makes your service resilient to token expiration.
Key Takeaways: Architecting for Security, Flexibility, and Scalability
- Centralizing authentication and authorization with IdentityServer enables scalable, maintainable security.
- JWTs provide stateless, portable identity and authorization across distributed systems.
- OAuth 2.0 and OIDC establish secure, industry-standard flows for both user and machine authentication.
- Policy-based authorization allows for precise, auditable control over API access.
- ASP.NET Core and Duende IdentityServer together offer a battle-tested foundation for building secure, future-proof .NET ecosystems.
As you design, always balance usability, security, and operational complexity. Centralizing identity need not slow you down—it can, in fact, be the lever that enables rapid growth with confidence.
8 Advanced Architectural Patterns & Best Practices
The journey doesn’t end with simply wiring up IdentityServer and token validation. Real-world distributed systems demand architectures that balance security, usability, and operational excellence. Let’s explore some of the best patterns and practical guidance for modern .NET ecosystems.
8.1 Security Patterns for Microservices
API Gateway: Offloading Token Validation
As microservices proliferate, you face a decision: should each microservice validate tokens independently, or should you centralize this logic? An API Gateway (like Ocelot) often provides a strategic answer.
Key Benefits:
- Centralized Policy Enforcement: The gateway validates JWTs, checks scopes, and enforces authorization policies before requests ever reach your internal services.
- Reduced Duplication: Downstream services can trust that traffic is already authenticated and authorized, reducing boilerplate code and configuration.
- Unified Error Handling: All security errors are handled in one place, yielding consistent behavior and better observability.
Typical Flow:
- The gateway receives a request, validates the JWT (signature, audience, scopes).
- If the token is valid and the required scopes are present, the gateway forwards the request to the appropriate backend service.
- If invalid, the gateway returns an appropriate error (401/403), shielding your internal landscape.
Ocelot Example Configuration:
"Routes": [
{
"DownstreamPathTemplate": "/api/data",
"DownstreamScheme": "https",
"DownstreamHostAndPorts": [
{ "Host": "localhost", "Port": 5003 }
],
"UpstreamPathTemplate": "/data",
"AuthenticationOptions": {
"AuthenticationProviderKey": "IdentityApiKey",
"AllowedScopes": [ "api.read" ]
}
}
]
Registering JWT validation at the gateway:
builder.Services.AddAuthentication()
.AddJwtBearer("IdentityApiKey", options =>
{
options.Authority = "https://localhost:5001";
options.Audience = "myapi";
});
Considerations:
- For high-throughput, consider caching JWKS metadata at the gateway.
- Ensure gateway instances are highly available and scalable to prevent bottlenecks.
The Backend for Frontend (BFF) Pattern
The SPA revolution brought new UX potential but introduced security tradeoffs, especially around token storage. The BFF Pattern addresses this by moving sensitive token handling out of the browser and into a dedicated server-side “backend for frontend.”
Why BFF?
- Reduces XSS Risk: Tokens are never stored in the browser’s localStorage/sessionStorage, preventing theft via XSS.
- Centralizes OIDC Flows: The BFF manages user authentication (with OIDC/OAuth2) server-side, holding tokens in secure HttpOnly cookies.
- Simplifies Client Logic: SPAs or mobile clients interact with their BFF via session, just like classic web apps, delegating authentication complexity.
BFF Workflow:
- The browser talks only to the BFF over HTTPS.
- The BFF handles login via OIDC, stores tokens in secure cookies, and interacts with APIs on the user’s behalf.
- No tokens are exposed to browser JavaScript.
Implementing BFF in .NET:
The Duende BFF library provides turnkey support for this pattern in ASP.NET Core. The key is to move authentication logic from the frontend to your server, using standard cookie authentication and proxying API calls as needed.
When to Use BFF:
- Any time you want to protect sensitive tokens from browser exposure.
- When your frontend team wants to focus on UX, not security minutiae.
8.2 Scaling IdentityServer
As your usage grows, so do operational demands. Large-scale identity systems need high availability, resilience, and performance.
Distributed Cache for Operational Data
IdentityServer (and ASP.NET Core Identity) persist operational data—like user sessions, authorization codes, refresh tokens, and consent—by default in a relational database. At scale, you may need to use a distributed cache (such as Redis) to avoid single points of failure and speed up token/session lookups.
How to Set Up:
- Use a distributed cache library, such as Microsoft.Extensions.Caching.StackExchangeRedis.
- In production, configure IdentityServer’s persisted grants store to use your distributed cache provider.
- Tune cache expiration policies according to your token/session lifetimes.
Sample Configuration:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 3600;
options.DistributedCache = builder.Services.BuildServiceProvider()
.GetRequiredService<IDistributedCache>();
});
Key-Signing Material for Stateless Validation
To validate JWTs across a cluster, all instances of IdentityServer (and any API trusting its tokens) must use the same signing key(s). In production, this usually means deploying:
- A certificate from a secure PKI.
- Hardware Security Modules (HSMs) for very high-security environments.
- Azure Key Vault, AWS KMS, or similar cloud-managed keys.
This enables any backend service or API to validate a token’s signature locally and statelessly, at wire speed.
Operational Best Practices:
- Rotate signing keys regularly, supporting key rollover and old token validation during migration.
- Never store certificates or secrets in source control. Use environment variables or secret management solutions.
8.3 Federation: Integrating External Identity Providers
Few organizations want to manage every user and credential in-house. By federating with external identity providers, you leverage existing authentication systems, enable Single Sign-On, and simplify onboarding.
IdentityServer supports federation out-of-the-box. Common scenarios include:
- Users log in with Google, Azure AD, Microsoft, or Facebook accounts.
- Employees authenticate with their corporate directory (Active Directory, ADFS, or other OIDC/SAML providers).
Sample: Adding Google Login
First, register your IdentityServer app in the Google Developer Console to obtain a client ID and secret.
Then, in your IdentityServer Program.cs:
builder.Services.AddAuthentication()
.AddGoogle("Google", options =>
{
options.ClientId = builder.Configuration["Authentication:Google:ClientId"];
options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
});
IdentityServer’s login page will now offer a “Login with Google” option, and federated users will be mapped into your ASP.NET Core Identity user store.
For Azure AD or OIDC providers: Use .AddOpenIdConnect() and map claims as needed.
Considerations:
- Define how external users are provisioned in your system (automatic vs. admin approval).
- Sync group memberships or roles where necessary for authorization.
8.4 Token Exchange and Delegation
In advanced scenarios, you may need to enable secure, delegated access between APIs—where one API calls another on behalf of the user, preserving user context and minimizing over-privileging.
OAuth 2.0 Token Exchange allows an API to trade an existing access token for a new one, tailored to a different API or set of scopes.
Example Use Case:
- API A receives a user’s access token.
- To fulfill a request, API A needs to call API B, but with only a subset of the user’s permissions.
- API A exchanges its token for a new access token (with constrained scopes) via IdentityServer.
IdentityServer supports the RFC8693 token exchange spec via Duende’s commercial extensions.
Pattern Benefits:
- Secure, granular delegation.
- APIs do not receive overbroad tokens.
- Enables service chaining with preserved user identity.
Considerations:
- Token exchange flows must be tightly controlled and logged.
- Limit the delegation chain to minimize complexity and security risk.
9 Conclusion: The Architect’s Path Forward
9.1 Recapping the Journey
We started with the security demands of modern distributed systems, examined why traditional authentication approaches fall short, and explored how JWTs, OAuth 2.0, and OpenID Connect enable stateless, scalable authentication and authorization. We then built a real-world identity system with Duende IdentityServer, integrated robust ASP.NET Core solutions, and discussed how to secure APIs, web apps, and background services in a unified landscape.
Advanced patterns like API gateways, BFF, and federation make it possible to extend and future-proof your architecture, while best practices for scaling and operational resilience prepare you for real-world demands.
9.2 Core Principles for Architects
As you move forward, keep these enduring principles at the heart of your designs:
- Centralize Identity: Avoid “identity sprawl.” Let a single, authoritative IdP govern authentication and authorization.
- Embrace Standards: Use well-supported, open protocols—OAuth 2.0, OIDC, JWT—for maximum interoperability and maintainability.
- Choose the Right Flow: Match each client (browser, API, worker) to the most secure and appropriate OAuth/OIDC grant type.
- Design for Extensibility: Expect requirements to change. Use policy-based authorization, scopes, and claims to adapt quickly.
- Prioritize Security: Protect secrets, use HTTPS everywhere, rotate keys, and audit your logs.
9.3 The Future of Identity
The identity landscape continues to evolve rapidly. On the horizon are new standards and technologies that promise even greater security and usability:
- Passkeys and FIDO2: Hardware-backed, phishing-resistant authentication using devices you own—pushing the industry beyond passwords.
- Passwordless Authentication: Microsoft, Apple, and Google now offer passwordless logins; .NET’s identity ecosystem is quickly adopting these models.
- Decentralized Identity: Self-sovereign identity (SSI) aims to give users full control over their digital credentials, disrupting traditional IdP-centric models.
- Continuous Access Evaluation (CAE): Real-time revalidation of tokens based on risk or policy changes, closing the gap between static token lifetimes and real-world threats.
The .NET ecosystem, driven by Microsoft and the open-source community, is deeply engaged with these trends. Libraries and platforms—including Duende IdentityServer, Azure AD, and others—are already pioneering support for passkeys, WebAuthn, and modern authentication experiences.