OAuth 2.0 Explained for .NET Developers
OAuth 2.0 is an open standard that lets an application access an API on someone's behalf without ever seeing their password. Instead of credentials, the application gets an access token: a short-lived, limited-scope, permission slip issued by a trusted server. If you've written [Authorize] on an ASP.NET Core endpoint or called AddJwtBearer(), you've already used part of OAuth.
This is OAuth 2.0 explained the way .NET developers can understand it: each of its four roles mapped to the ASP.NET Core code you may already write. By the end, you'll know which part of your solution plays which role, what each one does, and what ASP.NET Core leaves open for you to decide and implement.
If you want a protocol-first walkthrough of every grant type, read OAuth 2.0: What It Is and How It Works. This post stays focused on how OAuth shows up in a .NET application.
What Is OAuth 2.0?
OAuth 2.0 is an authorization framework defined in RFC 6749. It answers one question: "Is this application allowed to do this thing?" It does not answer "Who is this user?" That's a different question, and we'll come back to answering it in the section “What OAuth 2.0 Is Not”.
OAuth replaces shared passwords with tokens. A token says, in effect, "the holder of this may read the product catalog until 10:15 AM." The API that receives the token doesn't need to know the user's password, and the application that holds the token can't do anything the token doesn't allow.
Why OAuth Exists
Before OAuth, if a third-party app needed your data, it asked for your username and password. That gave the app full access to your account, with no way to limit what it could do or revoke it short of changing your password. Given how many apps you may use and your password hygiene, that may be a security nightmare.
OAuth 1.0 (2010) introduced delegated access. OAuth 2.0 (2012) replaced it with a simpler, more flexible design that fits web apps, mobile apps, and APIs. The IETF maintains it today, and OAuth 2.1 consolidates a decade of security guidance (PKCE everywhere, no implicit flow) into a single document.
The same problem exists inside your own systems. A background worker that calls your mission-critical API shouldn't hold a database password or a god-mode API key. Ideally, it should hold a token that allows exactly what it needs, exactly what OAuth provides.
The Four OAuth Roles, Mapped to ASP.NET Core
OAuth defines four roles. Each one maps to something concrete in a .NET solution.
| OAuth role | What it does | In your .NET solution |
|---|---|---|
| Resource owner | Grants permission | The signed-in user (or, with no user present, the organization that registered the client) |
| Client | Requests tokens and calls APIs | An ASP.NET Core web app with |
| Authorization server | Authenticates, gets consent, issues tokens | Duende IdentityServer, or another OAuth server. ASP.NET Core doesn't include one |
| Resource server | Validates tokens and serves data | Your ASP.NET Core API with |
OAuth separates these roles on purpose. Your API never sees a password. Your web app never decides whether a token is valid for the API. The authorization server is the only component that issues tokens, so it's the one place to enforce login policy, multi-factor authentication, and token lifetimes.
The rest of this section walks through each role with a .NET 10 minimal API example. The examples use one shared setup: the authorization server at https://localhost:5001, the API at https://localhost:6001, and a web app at https://localhost:7001.
Resource Server: Your API
The resource server is the role you're most likely to have built already. It receives a request with an Authorization: Bearer <token> header, validates the token, and decides whether the caller may proceed.
In ASP.NET Core, the Microsoft.AspNetCore.Authentication.JwtBearer package does the validation:
Csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Where to download signing keys and metadata from
options.Authority = "https://localhost:5001";
// This API's name; tokens must be issued for it
options.Audience = "catalog";
// Only accept access tokens, not ID tokens (RFC 9068)
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
// Keep claim names as issued ("scope", "sub")
options.MapInboundClaims = false;
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("catalog.read", policy => policy
.RequireAuthenticatedUser()
.RequireClaim("scope", "catalog.read"));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/products", () => new[] { "Keyboard", "Mouse", "Monitor" })
.RequireAuthorization("catalog.read");
app.Run(); Notice what the API doesn't do. It doesn't call the authorization server on every request, and it doesn't know how the user signed in. AddJwtBearer() downloads the authorization server's public signing keys once from its discovery document, then checks each token's signature, issuer, audience, lifetime, and type locally. The catalog.read policy turns an OAuth scope into an ASP.NET Core authorization rule.
AddJwtBearer() works with any standards-compliant authorization server (such as Duende IdentityServer). Swapping providers means changing Authority and Audience, not your endpoints. For the full set of validation options, see Protecting APIs in the Duende docs.
Client: A Web App Acting for a User
The client is the application that wants to call the API. When a user is involved, the client sends the user's browser to the authorization server to sign in and approve access. It receives an authorization code and exchanges it for tokens over a back channel.
This is the authorization code flow with PKCE (Proof Key for Code Exchange, pronounced "pixy"). PKCE binds the code to the client that started the flow, so a stolen code is useless to an attacker.
In ASP.NET Core, AddOpenIdConnect() runs this whole flow for you:
Csharp
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://localhost:5001";
options.ClientId = "web";
options.ClientSecret = "secret"; // load from configuration in real apps
options.ResponseType = "code"; // authorization code flow
options.UsePkce = true; // the default, shown for clarity
// "openid" and "profile" are requested by default
options.Scope.Add("catalog.read");
options.SaveTokens = true; // keep the access token in the auth cookie
options.MapInboundClaims = false;
});
builder.Services.AddHttpClient();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/products", async (HttpContext http, IHttpClientFactory factory) =>
{
var accessToken = await http.GetTokenAsync("access_token");
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
return await client.GetStringAsync("https://localhost:6001/products");
})
.RequireAuthorization();
app.Run(); The browser only ever holds an encrypted session cookie. The server can access the access token only from that cookie, and the web app attaches the token to outgoing API calls.
This example reads the token manually to keep the OAuth mechanics visible. In production, access tokens expire (see best practices for token expiration, refresh, and revocation), and you'll want something to refresh them. Duende.AccessTokenManagement.OpenIdConnect handles refresh and attaches tokens to HttpClient calls for you. See Web Apps in the docs.
If your front end is a single-page app (React, Angular, Blazor WebAssembly), the same principle applies. Keep tokens out of the browser by running the OAuth client on a server. That's the Backend for Frontend pattern, and the Duende BFF Security Framework provides components for it.
Client: A Worker With No User
Not every client acts for a user. For example, a background job that syncs a product catalog for an e-commerce platform every night has no browser and no one to sign in. The background job authenticates as itself using the client credentials flow: it sends its client ID and secret straight to the authorization server's token endpoint and gets back an access token.
You could write the token request, caching, and renewal yourself. Duende.AccessTokenManagement (open source) does it for you and plugs into IHttpClientFactory:
Csharp
using Duende.AccessTokenManagement;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddClientCredentialsTokenManagement()
.AddClient(ClientCredentialsClientName.Parse("catalog.worker"), client =>
{
client.TokenEndpoint = new Uri("https://localhost:5001/connect/token");
client.ClientId = ClientId.Parse("catalog.worker");
client.ClientSecret = ClientSecret.Parse("secret"); // load from configuration
client.Scope = Scope.Parse("catalog.read");
});
builder.Services.AddClientCredentialsHttpClient(
"catalog",
ClientCredentialsClientName.Parse("catalog.worker"),
client => client.BaseAddress = new Uri("https://localhost:6001/"));
builder.Services.AddHostedService<CatalogSync>();
builder.Build().Run();
public class CatalogSync(IHttpClientFactory factory, ILogger<CatalogSync> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// The handler requests, caches, and attaches the access token
var client = factory.CreateClient("catalog");
var products = await client.GetStringAsync("products", stoppingToken);
logger.LogInformation("Synced catalog: {Products}", products);
}
} Your business code calls GetStringAsync() and never touches a token. The same API from the previous section accepts this token, because it carries the same catalog.read scope. The API doesn't care whether a person or a process is on the other end; it only cares what the token allows.
Authorization Server: The Infrastructure ASP.NET Core Doesn't Include
ASP.NET Core provides components for the resource server and client roles. It does not include an OAuth authorization server. Bearer tokens from ASP.NET Core Identity's MapIdentityApi() endpoints use a proprietary format meant for first-party apps. They aren't OAuth access tokens, and other services can't validate them with AddJwtBearer().
The authorization server registers clients, APIs, and scopes. It signs users in, records consent, and issues tokens. Duende IdentityServer is a standards-based authorization server and OpenID Connect provider that runs inside your own ASP.NET Core app. Here's the configuration that supports both clients above:
Csharp
using Duende.IdentityServer.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddIdentityServer()
.AddInMemoryIdentityResources(
[
new IdentityResources.OpenId(),
new IdentityResources.Profile()
])
.AddInMemoryApiScopes([new ApiScope("catalog.read")])
.AddInMemoryApiResources(
[
new ApiResource("catalog") { Scopes = { "catalog.read" } }
])
.AddInMemoryClients(
[
// Interactive web app: authorization code + PKCE
new Client
{
ClientId = "web",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { "https://localhost:7001/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:7001/signout-callback-oidc" },
AllowedScopes = { "openid", "profile", "catalog.read" }
},
// Background worker: client credentials, no user
new Client
{
ClientId = "catalog.worker",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "catalog.read" }
}
]);
// ... add login and consent pages, and a user store
var app = builder.Build();
app.UseIdentityServer();
// ...
app.Run(); Each line maps to something you've already seen. ApiResource("catalog") is why the API's Audience is "catalog". ApiScope("catalog.read") is the scope both clients request and the API's policy requires. GrantTypes.Code and GrantTypes.ClientCredentials are the two flows from the diagrams above. IdentityServer requires PKCE for code-flow clients by default.
In-memory configuration works for learning and tests. For production, load configuration from a database with Entity Framework Core stores and follow the quickstarts to add a login UI.
Resource Owner: The User (or Nobody)
The resource owner is whoever can grant access. In the web app example, that's the signed-in user, who may see a consent screen listing the scopes the app requested. In the worker example, there's no user. The organization that registered the catalog.worker client granted access ahead of time by allowing it the catalog.read scope.
Scopes: How Permission Travels From Login to [Authorize]
Scopes tie the four roles together. Follow catalog.read through the system:
- IdentityServer defines
catalog.readas an API scope and allows thewebandcatalog.workerclients to request it. - The client requests
catalog.readinAddOpenIdConnect()orScope.Parse(). - The user (when present) approves it.
- IdentityServer puts
"scope": "catalog.read"and"aud": "catalog"into the access token. - The API checks the audience in
AddJwtBearer()and the scope in thecatalog.readpolicy.
Design scopes around what callers do (catalog.read, orders.write), not around who they are. Request only the scopes a client needs. Overly broad scopes are a common security pitfall in OAuth 2.0 implementations.
What OAuth 2.0 Is Not
Most OAuth confusion comes from expecting it to do more than it does.
OAuth is not authentication. An access token tells an API what the caller may do. It doesn't reliably tell the client who the user is. That's the job of OpenID Connect (OIDC), an identity layer built on top of OAuth 2.0. OIDC adds the ID token, a standard way for the client to learn who signed in. When you call AddOpenIdConnect(), you're using both: OIDC to sign the user in, OAuth to get an access token for the API. Read more in What Is OpenID Connect and How It Helps Developers.
The two tokens go to different places. The ID token stays with the client. The access token goes to the API. Never send an ID token to an API as a bearer token. The ValidTypes = ["at+jwt"] line in the API example rejects them. For the full picture, see How Access Tokens and ID Tokens Flow Through OAuth and OpenID Connect.
OAuth is not a token format. Access tokens are often JSON Web Tokens (JWTs, pronounced "jot"), but OAuth doesn't require them. Some authorization servers issue opaque reference tokens that APIs validate by calling an introspection endpoint. If you're working with JWTs, read best practices for using JWTs with web and mobile apps.
Issuing your own JWTs is not OAuth. Many tutorials, including our own controller-based JWT tutorial, have the API sign its own tokens so you can see validation in isolation. That's useful for learning. In production, a separate authorization server that issues tokens gives you central control over login, revocation, and key rotation.
OAuth is not a complete security solution. It still depends on HTTPS everywhere, short token lifetimes, safe redirect URI registration, and secure client secret storage. RFC 9700, the OAuth 2.0 Security Best Current Practice, is the current checklist.
Where to Go Next
You now have the map. Your API is the resource server, validating tokens with AddJwtBearer(). Your web apps and workers are clients, getting tokens with AddOpenIdConnect() and Duende.AccessTokenManagement. The authorization server is the piece you add, and scopes connect them all.
To go deeper:
- Pick the right flow for each client. OAuth 2.0: What It Is and How It Works covers every grant type and which ones are deprecated.
- Build it end to end. The Duende IdentityServer quickstarts take you from an empty folder to a working authorization server, API, and web client.
- Try it without installing anything. Point the examples above at the Duende demo server to see real tokens.
Duende IdentityServer is free for development, testing, and personal projects. When you're ready to run your own authorization server, start for free.