OAuth 2.1 Made Simple: The Only Flows You Need

Khalid Abuhakmeh |

Back in 2019, Dominick Baier, Duende Cofounder and Security subject-matter expert, wrote a prophetic post called "Two is the Magic Number", a riff on De La Soul's "Three is the Magic Number", arguing that you only needed two OAuth flows to cover every real-world scenario. At the time, it was a bold simplification. OAuth 2.0 had shipped with a sprawl of grant types: Implicit, Resource Owner Password Credentials, Authorization Code without PKCE, Client Credentials, and the ecosystem dutifully built tutorials for all of them. The result was confusion. Developers picked the wrong flow, shipped insecure apps, and blamed OAuth itself for being "too complicated."

Dominick was right, and the standards body agreed. OAuth 2.1 formally removed the footguns, mandated PKCE on every authorization code grant, and left us with a protocol that is dramatically simpler to learn and harder to misuse. If you're building on .NET 10 in 2026, this is the only article you need. Two flows cover almost everything. A third handles the edge case. Let's go.

Flow 1: Client Credentials

For: Server-to-server communication (no user involved)

Sometimes software talks to other software, and no human is anywhere near the conversation. A nightly batch job pulls records from an API. A shipping microservice notifies an inventory microservice that stock levels have changed. A background worker processes a queue. In all of these cases, the service itself is the identity — it acts on its own behalf, not on behalf of a user. That's where Client Credentials flow comes in.

The flow is dead simple. Your service presents its credentials to the token service and gets back an access token. No browser redirects, no user interaction, no PKCE. Just a direct, back-channel HTTP POST. The token service verifies the client's identity and returns a token scoped to whatever permissions that client has been granted.

How the client proves its identity is worth a quick mention. The most common method is a shared secret: a client_id and client_secret sent in the request body or as a Basic auth header. For higher-security environments, you can use private_key_jwt, where the client signs a JWT assertion with its private key and the token service validates it against the registered public key. The strongest option is mutual TLS (mTLS), where the client authenticates at the transport layer using an X.509 certificate. All three work with Client Credentials; choose the one most applicable to your threat model.

sequenceDiagram
    participant Service A
    participant Token Service
    Service A->>Token Service: client_id + client_secret
    Token Service->>Service A: access_token
// .NET 10 — requesting a client credentials token
var client = new HttpClient();

var response = await client.RequestClientCredentialsTokenAsync(
    new ClientCredentialsTokenRequest
    {
        Address = "https://identity.example.com/connect/token",
        ClientId = "service-a",
        ClientSecret = "secret",
        Scope = "api1.read api1.write"
    });

var accessToken = response.AccessToken;

When to use: background services calling APIs, microservice-to-microservice communication, daemon processes. Any scenario where no human user is involved.

Flow 2: Authorization Code + PKCE

For: Everything involving a user (web apps, native apps, SPAs via BFF)

If a human is logging in, this is your flow. Full stop. It doesn't matter whether you're building a server-rendered Razor Pages app, a native iOS client, a desktop application, or a single-page app behind a backend-for-frontend. Authorization Code with PKCE is the universal answer for interactive authentication in OAuth 2.1. The old world gave you choices: Implicit for SPAs, Resource Owner Password Credentials (ROPC), and if you "trusted" the client, plain Authorization Code for server apps. OAuth 2.1 collapsed all of that into a single flow and made PKCE mandatory. Less choice, more security.

Here's how it works. Your application redirects the user to the identity provider's authorization endpoint. The user authenticates by entering credentials, completing MFA, and fulfilling any required policy, and the identity provider redirects back to your application with a short-lived authorization code. Your application then exchanges that code for tokens via a direct back-channel call to the token endpoint. The user's credentials never touch your application. The tokens never pass through the browser's address bar. That's the whole point.

Proof Key for Code Exchange (PKCE, pronounced "pixie," RFC 7636) adds a critical layer of protection to this exchange. Before starting the flow, your application generates a random string called a code_verifier and derives a code_challenge from it using SHA-256. The challenge is sent along with the initial authorization request. When your app later exchanges the authorization code for tokens, it sends the original verifier. The token service hashes the verifier, compares it to the stored challenge, and only issues tokens if they match. This prevents an attacker who intercepts the authorization code — through a malicious app registered on the same custom URI scheme, a compromised redirect, or any other vector — from using it. Without the original verifier, the code is worthless. OAuth 2.1 requires PKCE on every authorization code grant, and .NET enables it by default.

sequenceDiagram
    participant User/Browser
    participant App
    participant Identity Provider
    User/Browser->>App: Initiate login
    App->>Identity Provider: Authorization request + code_challenge
    Identity Provider->>User/Browser: Login page
    User/Browser->>Identity Provider: Authenticate
    Identity Provider->>App: Authorization code
    App->>Identity Provider: Exchange code + code_verifier (back-channel)
    Identity Provider->>App: Tokens
// .NET 10 — configuring OIDC with Code + PKCE
builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = "cookie";
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie("cookie")
    .AddOpenIdConnect("oidc", options =>
    {
        options.Authority = "https://identity.example.com";
        options.ClientId = "web-app";
        options.ClientSecret = "secret";
        options.ResponseType = "code";        // Authorization Code Flow
        options.UsePkce = true;               // PKCE (default in .NET)
        options.SaveTokens = true;
        
        options.Scope.Add("openid");
        options.Scope.Add("profile");
        options.Scope.Add("api1.read");
    });

When to use: server-side web applications (MVC, Razor Pages, Blazor Server), native mobile apps (iOS, Android), desktop applications, SPAs via the BFF pattern. Basically, any app that lets a human log in.

Note: It is currently the industry BCP to secure all client-side web applications built with JavaScript using the Backend-For-Frontend pattern. If your client-side JavaScript apps store tokens in the browser, you are putting both tokens and users at risk.

Flow 3: Device Authorization (Bonus)

For: Devices without browsers or with limited input (TVs, IoT, CLI tools)

Some devices don't have a browser or a decent keyboard. Think of a smart TV, a gaming console, a CLI tool running in a headless environment, or an IoT sensor with a tiny screen. You can't redirect a user to a login page that doesn't exist. Device Authorization (RFC 8628) solves this with a decoupled pattern. The device requests a user code and a verification URL from the token service, then displays both to the user. For example, like "Go to https://login.example.com/device and enter code ABCD-1234." The user picks up their phone or laptop, navigates to that URL, enters the code, and authenticates normally. Meanwhile, the device polls the token endpoint at a specified interval, receiving authorization_pending responses until the user completes the flow, at which point it gets back an access token. It's simple, secure, and doesn't require the constrained device to render a login UI.

sequenceDiagram
    participant TV/IoT Device
    participant Token Service
    participant User's Phone
    TV/IoT Device->>Token Service: Request device code
    Token Service->>TV/IoT Device: device_code + user_code + verification_url
    TV/IoT Device->>User's Phone: Display: "Go to https://login.example.com/device<br/>Enter code: ABCD-1234"
    User's Phone->>Token Service: Navigate to URL and enter code
    User's Phone->>Token Service: Authenticate and approve
    loop Polling
        TV/IoT Device->>Token Service: Poll with device_code
        Token Service-->>TV/IoT Device: authorization_pending
    end
    Token Service->>TV/IoT Device: access_token (when user completes)

When to use: smart TVs, gaming consoles, CLI tools, IoT devices. Anything without a browser or a usable keyboard.

The OAuth 2.1 Decision Tree

Picking the right flow takes about five seconds. Start with one question: Is a human user involved? If not, use Client Credentials. If yes, ask whether the device has a browser. If it does, use Authorization Code with PKCE, regardless of whether it's a server-side web app, a native mobile app, or an SPA behind a BFF. If there's no browser, use Device Authorization. That's the entire decision tree.

flowchart TD
    A[Is a human user involved?] -->|NO| B[Client Credentials Flow]
    A -->|YES| C[Does the device have a browser?]
    C -->|YES| D[Authorization Code + PKCE]
    C -->|NO| E[Device Authorization Flow]
    D --> F[Server-side app?]
    D --> G[Native app?]
    D --> H[SPA?]
    F --> I[Direct OIDC integration]
    G --> J[System browser + PKCE AppAuth]
    H --> K[BFF pattern - server does the OIDC]

What OAuth 2.1 Removed (and Why?)

You don't need to learn these concepts or ideas unless you're in the process of migrating away from them. If you see them in old tutorials, ignore them. But if you're curious about why they were cut, here's the short version.

Implicit Flow was designed for SPAs in an era when browsers couldn't make cross-origin POST requests. It returned access tokens directly in the URL fragment. That meant tokens showed up in browser history, got leaked through referrer headers, and landed in server logs. There was no mechanism for refresh tokens, no way to bind tokens to specific clients, and an enormous attack surface. The moment CORS became universally supported, the rationale for Implicit evaporated. Authorization Code with PKCE does everything Implicit did, without exposing tokens in URLs.

Resource Owner Password Credentials (ROPC) asked users to type their username and password directly into the client application. This fundamentally defeats the purpose of OAuth, which exists to keep user credentials away from third-party apps. ROPC couldn't support multi-factor authentication or federated login, and it trained users to hand their credentials to apps that shouldn't have them. It was a migration crutch for legacy systems, and it overstayed its welcome.

Authorization Code without PKCE was the original server-side flow, and it “worked” fine until it didn't. On mobile platforms, multiple apps can register the same custom URI scheme, which means a malicious app could intercept the authorization code during the redirect. Without PKCE, that intercepted code could be exchanged for real tokens. PKCE closes this gap entirely by binding the code to a cryptographic proof that only the original client possesses. OAuth 2.1 no longer offers a "without PKCE" option. PKCE is just how authorization code grants work now.

Beyond choosing the right flow, OAuth 2.1 also benefits from complementary hardening mechanisms. Refresh tokens require careful lifecycle management, including rotation, replay detection, and sender-constraining. And for high-security environments, proof-of-possession tokens (DPoP and mTLS) bind access tokens to the client that requested them, ensuring stolen tokens are useless without the corresponding private key.

Bonus: Token Exchange (RFC 8693)

Token Exchange isn't a user-facing flow. It's plumbing for microservice architectures. Picture this: a user makes a request to your API gateway, which holds an access token issued to the user. The gateway needs to call a downstream billing service, but the billing service requires a token with a narrower scope and a different audience. Instead of passing the original token along (over-privileged and insecure) or falling back to Client Credentials (which loses the user context entirely), the gateway uses Token Exchange (RFC 8693) to swap the user's token for a new one scoped specifically to the billing service. The token service mints a constrained token that carries the user's identity but is only valid for the downstream resource. Delegation without credential sharing.

Bonus: Client-Initiated Backchannel Authentication (CIBA)

CIBA flips the authentication model on its head. Instead of the user initiating a login, the application asks the identity provider to authenticate the user on a separate device. Think of a bank teller processing a large withdrawal: the teller's application sends a CIBA request to the identity provider, which pushes a notification to the customer's phone stating, "Do you approve a $5,000 withdrawal at the downtown branch?" The customer taps approve, the identity provider issues tokens back to the teller's application, and the transaction proceeds. The same pattern works for helpdesk scenarios, step-up authentication, and any flow where the person authorizing the action isn't sitting at the device that initiated it. See the CIBA specification for the full details.

Setting Up Your Token Service

Here's a Duende IdentityServer v8 configuration that supports all three core flows. Note that RequirePkce is set to true on every interactive client. OAuth 2.1 mandates it, and your token service should enforce it.

// Program.cs — Duende IdentityServer v8 on .NET 9
builder.Services.AddIdentityServer()
    .AddInMemoryApiScopes(new[]
    {
        new ApiScope("api1.read", "Read access to API 1"),
        new ApiScope("api1.write", "Write access to API 1"),
    })
    .AddInMemoryApiResources(new[]
    {
        new ApiResource("api1", "API 1")
        {
            Scopes = { "api1.read", "api1.write" }
        }
    })
    .AddInMemoryIdentityResources(new[]
    {
        new IdentityResources.OpenId(),
        new IdentityResources.Profile(),
    })
    .AddInMemoryClients(new[]
    {
        // 1. Confidential web app — Authorization Code + PKCE
        new Client
        {
            ClientId = "web-app",
            ClientName = "Server-Side Web Application",
            AllowedGrantTypes = GrantTypes.Code,
            RequirePkce = true,
            ClientSecrets = { new Secret("web-app-secret".Sha256()) },
            RedirectUris = { "https://webapp.example.com/signin-oidc" },
            PostLogoutRedirectUris = { "https://webapp.example.com/signout-callback-oidc" },
            AllowedScopes = { "openid", "profile", "api1.read" },
            AllowOfflineAccess = true   // enables refresh tokens
        },

        // 2. Native public client — Authorization Code + PKCE (no secret)
        new Client
        {
            ClientId = "native-app",
            ClientName = "Native Mobile / Desktop App",
            AllowedGrantTypes = GrantTypes.Code,
            RequirePkce = true,
            RequireClientSecret = false, // public client
            RedirectUris = { "com.example.nativeapp://callback" },
            PostLogoutRedirectUris = { "com.example.nativeapp://signout" },
            AllowedScopes = { "openid", "profile", "api1.read", "api1.write" },
            AllowOfflineAccess = true
        },

        // 3. Device client — Device Authorization Flow
        new Client
        {
            ClientId = "device-app",
            ClientName = "TV / IoT Device",
            AllowedGrantTypes = GrantTypes.DeviceFlow,
            RequireClientSecret = false, // device clients are typically public
            AllowedScopes = { "openid", "profile", "api1.read" },
            AllowOfflineAccess = true
        }
    });

Three client definitions, three scenarios, one token service. The confidential web app uses a shared secret and obtains refresh tokens to maintain long-lived sessions. The native app is a public client — no secret, because mobile and desktop binaries can't keep secrets — and relies entirely on PKCE for security. The device client uses Device Authorization Flow and is also public. Each client gets only the scopes it needs.

Conclusion

OAuth 2.1 did the hard work of saying "no" so you don't have to. Client Credentials for machines. Authorization Code with PKCE for humans. Device Authorization when there's no browser. Token Exchange and CIBA are there when you need delegation or decoupled authentication. That's the whole protocol, and it fits on an index card. Stop reading OAuth tutorials. Go build something.


Thanks for stopping by!

We hope this post helped you on your identity and security journey. If you need a hand with implementation, our docs are always open. For everything else, come hang out with the team and other developers on GitHub.

If you want to get early access to new features and products while collaborating with experts in security and identity standards, join us in our Duende Product Insiders program. And if you prefer your tech content in video form, our YouTube channel is the place to be. Don't forget to like and subscribe!

Questions? Comments? Just want to say hi? Leave a comment below and let's start a conversation.