OAuth 2.1 & OpenID Connect: Modern API Authentication in 2026

Posted on: 4/21/2026 7:19:14 PM

If your application still uses the Implicit Flow, or allows clients to submit username and password directly via Resource Owner Password Credentials (ROPC) — those patterns have been officially removed. OAuth 2.1 isn't a minor update — it consolidates the OAuth 2.0 core specification with years of security best practices from multiple RFCs into a single document, eliminating everything that's proven unsafe.

This article dives deep into the core changes in OAuth 2.1, how OpenID Connect adds the identity layer on top, and provides a hands-on implementation guide for ASP.NET Core .NET 10 with the latest patterns: mandatory PKCE, DPoP, Pushed Authorization Requests (PAR), and the BFF pattern for SPAs.

RFC draft-15Current version of OAuth 2.1 spec
100%PKCE mandatory for all Authorization Code flows
0Implicit Flow and ROPC — removed entirely
DPoPSender-constrained tokens — preventing token theft

1. OAuth 2.0 Is Outdated — Why Do We Need 2.1?

OAuth 2.0 (RFC 6749) was published in 2012 — when SPAs were uncommon, mobile apps were primitive, and the threat model was far simpler. Over 14 years, the community discovered numerous vulnerabilities and published several supplementary RFCs (PKCE, Browser-Based Apps BCP, Security BCP, Native Apps BCP...). The problem: developers had to read 7+ separate RFCs to understand "the right way to do it."

OAuth 2.1 solves this by merging all best practices into a single document and removing everything that's insecure.

2012 — OAuth 2.0 (RFC 6749)
Launched with 4 grant types: Authorization Code, Implicit, ROPC, Client Credentials. Implicit was recommended for SPAs.
2015 — PKCE (RFC 7636)
Proof Key for Code Exchange — initially designed for mobile apps only, later became best practice for all clients.
2017 — OAuth 2.0 for Native Apps (RFC 8252)
Recommended using system browser instead of embedded WebView for mobile OAuth.
2021 — OAuth 2.0 Security BCP (RFC 9700)
Officially recommended NOT using Implicit Flow. PKCE should be mandatory for all clients.
2023 — DPoP (RFC 9449)
Demonstrating Proof-of-Possession — binds tokens to a specific client, preventing token theft.
2024–2026 — OAuth 2.1 (draft-ietf-oauth-v2-1)
Merges all above RFCs into a unified spec. PKCE mandatory, Implicit and ROPC removed.

2. What Changes in OAuth 2.1 vs 2.0?

ChangeOAuth 2.0OAuth 2.1
PKCEOptional (only recommended for public clients)Mandatory for ALL Authorization Code flows
Implicit FlowAvailable, commonly used for SPAsCompletely removed
ROPC GrantAvailable (client handles user's password)Completely removed
Redirect URIAllowed wildcard matchingExact string matching — bit-for-bit
Refresh TokenPlain bearer tokenMust be sender-constrained (mTLS/DPoP) or rotated on every use
Bearer Token in URIAllowed ?access_token=xxxProhibited — Authorization header only

⚠️ Real-World Breaking Changes

If your SPA uses Implicit Flow (response_type=token), you must migrate to Authorization Code + PKCE. If your mobile app uses ROPC (sending username/password directly), switch to system browser + Authorization Code. This isn't a recommendation — it's required under OAuth 2.1.

3. PKCE — Why Mandatory for Every Client?

PKCE (Proof Key for Code Exchange, pronounced "pixie") was originally designed to protect mobile apps from authorization code interception attacks. But the threat model has expanded: even confidential clients (server-side) can be attacked via cross-site request forgery or code injection without PKCE.

sequenceDiagram
    participant App as Client App
    participant AS as Authorization Server
    participant RS as Resource Server

    Note over App: Generate code_verifier (random 43-128 chars)
    Note over App: Compute code_challenge = SHA256(code_verifier)

    App->>AS: GET /authorize?response_type=code
&code_challenge=abc123
&code_challenge_method=S256
&client_id=xxx&redirect_uri=... AS-->>App: Redirect with authorization_code App->>AS: POST /token
code=auth_code
&code_verifier=original_random_string Note over AS: Verify: SHA256(code_verifier) == code_challenge? AS-->>App: access_token + refresh_token App->>RS: GET /api/resource
Authorization: Bearer {access_token} RS-->>App: 200 OK + data

PKCE flow: code_verifier never leaves the client until the token exchange step

Protection mechanism: Even if an attacker intercepts the authorization_code (via phishing redirect, malicious app on the same device...), they cannot exchange it for an access token because they don't have the code_verifier — which only exists in the original client app's memory.

4. DPoP — Sender-Constrained Tokens

Bearer tokens have an inherent weakness: whoever possesses the token has the permissions. If a token is stolen (via logs, proxy, XSS...), the attacker can use it immediately. DPoP (Demonstrating Proof-of-Possession) solves this by binding tokens to a client's unique key pair.

sequenceDiagram
    participant C as Client
    participant AS as Auth Server
    participant RS as Resource Server

    Note over C: Generate asymmetric keypair (once)

    C->>AS: POST /token + DPoP proof JWT
(signed with private key) AS-->>C: access_token (with cnf.jkt claim = hash of public key) C->>RS: GET /api/data
Authorization: DPoP {access_token}
DPoP: {new proof JWT, signed with private key} Note over RS: Verify: proof JWT signature
+ access_token.cnf.jkt == hash(proof.jwk)? RS-->>C: 200 OK Note over C,RS: ❌ Attacker has access_token but NOT the private key Note over C,RS: → Cannot create DPoP proof → Request rejected

DPoP: a stolen token becomes useless without the client's private key

✅ When to Use DPoP?

DPoP is especially important for: (1) High-privilege APIs (financial, healthcare), (2) Environments with high token leakage risk (mobile, SPA), (3) When compliance requires sender-constrained tokens (FAPI 2.0, Open Banking). For internal microservices, mTLS is usually a simpler alternative.

5. OpenID Connect — The Identity Layer on Top of OAuth

OAuth 2.1 only handles authorization (who is allowed to do what). But most applications need authentication (who is this user). That's the role of OpenID Connect (OIDC) — an identity protocol layer built on top of OAuth 2.0/2.1.

AspectOAuth 2.1OpenID Connect
PurposeAuthorization — grant access to resourcesAuthentication — verify user identity
Primary Tokenaccess_token (opaque or JWT)id_token (always JWT) + access_token
User InfoNo standardStandardized /userinfo endpoint
DiscoveryNo standard.well-known/openid-configuration
ScopesArbitraryopenid required + profile, email...
SessionNot managedSession management, logout protocol
flowchart TD
    subgraph OIDC["OpenID Connect Layer"]
        ID[id_token JWT] --> CLAIMS["Claims: sub, name, email..."]
        UI[/userinfo endpoint/]
        DISC[/.well-known/openid-configuration/]
    end

    subgraph OAuth["OAuth 2.1 Layer"]
        AC[Authorization Code + PKCE]
        AT[access_token]
        RT[refresh_token]
        CC[Client Credentials]
    end

    subgraph HTTP["HTTP/TLS Layer"]
        TLS[HTTPS required]
        CORS[CORS policies]
    end

    OIDC --> OAuth
    OAuth --> HTTP

    style OIDC fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style OAuth fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style HTTP fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style ID fill:#e94560,stroke:#fff,color:#fff
    style AT fill:#2c3e50,stroke:#fff,color:#fff

OpenID Connect sits on top of OAuth 2.1, adding authentication and identity

6. BFF Pattern — The Right Way for SPAs to Use OAuth 2.1

After the Implicit Flow was removed, the biggest question is: how should SPAs handle authentication? The answer in 2026: the Backend for Frontend (BFF) pattern.

Instead of the SPA (JavaScript running in the browser) directly holding access tokens — where tokens can be stolen via XSS — BFF moves the entire OAuth flow to a lightweight backend. The SPA only uses HttpOnly cookies (which cannot be read by JavaScript).

sequenceDiagram
    participant SPA as Vue.js SPA
    participant BFF as BFF Server (.NET)
    participant IDP as Identity Provider
    participant API as Resource API

    SPA->>BFF: GET /bff/login
    BFF->>IDP: Authorization Code + PKCE
    IDP-->>BFF: code + id_token
    BFF->>IDP: POST /token (exchange code)
    IDP-->>BFF: access_token + refresh_token
    Note over BFF: Store tokens in server-side session
Return HttpOnly cookie to SPA BFF-->>SPA: Set-Cookie: .bff-session (HttpOnly, Secure, SameSite) SPA->>BFF: GET /bff/api/orders (cookie auto-sent) BFF->>API: GET /api/orders
Authorization: Bearer {access_token} API-->>BFF: 200 OK + data BFF-->>SPA: 200 OK + data

BFF Pattern: the SPA never touches tokens — everything goes through HttpOnly cookies

🔐 Why Not Use Authorization Code + PKCE Directly in the SPA?

Technically, a SPA can use Authorization Code + PKCE (this is the public client flow). But the access token will reside in JavaScript memory or sessionStorage — still vulnerable to XSS theft. The BFF pattern eliminates this risk entirely since tokens only exist server-side. This is the official recommendation of the OAuth 2.0 for Browser-Based Apps BCP and is adopted by Microsoft, Auth0, and Duende.

7. Pushed Authorization Requests (PAR)

PAR (RFC 9126) is a significant improvement: instead of putting all OAuth parameters on the URL (query string), the client sends them via POST to the authorization server first, receives a request_uri, then only sends that URI in the browser redirect.

// Step 1: Push authorization request (server-to-server)
POST /oauth/par HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic {client_credentials}

response_type=code
&client_id=my-app
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=xyz123

// Response
HTTP/1.1 201 Created
{
  "request_uri": "urn:ietf:params:oauth:request_uri:abc123",
  "expires_in": 60
}

// Step 2: Browser redirect (only request_uri)
GET /authorize?client_id=my-app&request_uri=urn:ietf:params:oauth:request_uri:abc123

Benefits: (1) Parameters aren't exposed in URL/browser history, (2) The request is authenticated (proving client legitimacy before the user sees the consent screen), (3) Avoids URL length limits with complex requests. ASP.NET Core .NET 10 enables PAR by default when the identity provider supports it.

8. Implementation on ASP.NET Core .NET 10

8.1. OIDC Client Configuration (Web App)

// Program.cs — ASP.NET Core .NET 10
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Strict;
})
.AddOpenIdConnect(options =>
{
    options.Authority = "https://auth.example.com";
    options.ClientId = builder.Configuration["Oidc:ClientId"]!;
    options.ClientSecret = builder.Configuration["Oidc:ClientSecret"]!;

    options.ResponseType = "code";   // Authorization Code flow
    options.UsePkce = true;          // PKCE — mandatory in OAuth 2.1

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.Scope.Add("api.read");

    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.MapInboundClaims = false; // Keep original claim names

    // PAR — .NET 10 auto-enables when server supports it
    options.PushedAuthorizationBehavior =
        PushedAuthorizationBehavior.UseIfAvailable;

    options.TokenValidationParameters = new()
    {
        NameClaimType = "name",
        RoleClaimType = "role"
    };
});

builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

8.2. BFF Proxy for Vue.js SPA

// BFF endpoints — proxy API calls with access token
app.MapGet("/bff/user", (HttpContext ctx) =>
{
    if (ctx.User.Identity?.IsAuthenticated != true)
        return Results.Unauthorized();

    return Results.Ok(new
    {
        name = ctx.User.FindFirst("name")?.Value,
        email = ctx.User.FindFirst("email")?.Value
    });
}).RequireAuthorization();

app.MapGet("/bff/api/{**path}", async (
    string path,
    HttpContext ctx,
    IHttpClientFactory clientFactory) =>
{
    var accessToken = await ctx.GetTokenAsync("access_token");
    if (accessToken is null) return Results.Unauthorized();

    var client = clientFactory.CreateClient("ResourceAPI");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", accessToken);

    var response = await client.GetAsync($"/api/{path}");
    var content = await response.Content.ReadAsStringAsync();

    return Results.Content(content, response.Content.Headers.ContentType?.MediaType);
}).RequireAuthorization();

8.3. Resource API — JWT Validation

// Resource API — validate access_token (JWT)
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.Authority = "https://auth.example.com";
        options.Audience = "api.example.com";

        options.TokenValidationParameters = new()
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

// Minimal API with authorization policy
app.MapGet("/api/orders", async (ClaimsPrincipal user, OrderService service) =>
{
    var userId = user.FindFirst("sub")?.Value
        ?? throw new UnauthorizedAccessException();
    return await service.GetOrdersByUserAsync(userId);
}).RequireAuthorization("api.read");

9. Identity Provider Comparison for .NET 2026

ProviderLicenseOAuth 2.1DPoPPARBest For
Keycloak 26Apache 2.0Self-hosted, Java ecosystem, enterprise
Duende IdentityServer 7Commercial ($)ASP.NET Core native, full control
OpenIddict 6Apache 2.0PartialLightweight, embedded in ASP.NET Core
Microsoft Entra IDSaaS ($)Azure ecosystem, enterprise SSO
Auth0 / OktaSaaS (free tier)Quick start, managed service

✅ Recommendation for Small Teams

Already on Azure → Microsoft Entra ID (built-in integration). Need free self-hosting → Keycloak (mature, feature-complete). Want to embed directly in your ASP.NET Core app → OpenIddict (lightweight, flexible). Duende IdentityServer is best when you need full customization and are willing to pay the license fee.

10. OAuth 2.1 Security Checklist

📋 Authorization Server

☐ PKCE mandatory for all Authorization Code flows
☐ Implicit Flow and ROPC completely disabled
☐ Redirect URI exact matching — no wildcards
☐ Refresh token rotation or sender-constrained (DPoP/mTLS)
☐ Short access token lifetime (5–15 minutes)
☐ Support for PAR (Pushed Authorization Requests)
☐ Token introspection endpoint for resource servers
☐ Rate limiting on /token and /authorize endpoints

📋 Client Application

☐ Always use Authorization Code + PKCE
☐ SPAs: use BFF pattern — don't store tokens in the browser
☐ Mobile: use system browser (not embedded WebView)
☐ Validate id_token: issuer, audience, expiry, nonce
☐ DPoP for high-privilege APIs
☐ Never store tokens in localStorage (use HttpOnly cookie or memory)
☐ Implement token refresh before access_token expires
☐ Handle 401 gracefully — redirect to login

📋 Resource API

☐ Validate JWT: signature, issuer, audience, expiry
☐ Clock skew tolerance ≤ 30 seconds
☐ Check scope/permission before returning data
☐ Don't trust client-side claims for authorization decisions
☐ Log authentication failures to detect brute force
☐ CORS only allows registered origins

OAuth 2.1 isn't about "adding new features" — it's about removing what's wrong and making best practices mandatory. If you're building a new system in 2026, start with OAuth 2.1 + OIDC + PKCE + BFF pattern. If you're maintaining a legacy system, prioritize migrating away from Implicit Flow and enabling PKCE — those are the two changes with the highest security impact for the lowest effort.

References