OAuth 2.1 & OpenID Connect: Modern API Authentication in 2026
Posted on: 4/21/2026 7:19:14 PM
Table of contents
- 1. OAuth 2.0 Is Outdated — Why Do We Need 2.1?
- 2. What Changes in OAuth 2.1 vs 2.0?
- 3. PKCE — Why Mandatory for Every Client?
- 4. DPoP — Sender-Constrained Tokens
- 5. OpenID Connect — The Identity Layer on Top of OAuth
- 6. BFF Pattern — The Right Way for SPAs to Use OAuth 2.1
- 7. Pushed Authorization Requests (PAR)
- 8. Implementation on ASP.NET Core .NET 10
- 9. Identity Provider Comparison for .NET 2026
- 10. OAuth 2.1 Security Checklist
- References
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.
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.
2. What Changes in OAuth 2.1 vs 2.0?
| Change | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | Optional (only recommended for public clients) | Mandatory for ALL Authorization Code flows |
| Implicit Flow | Available, commonly used for SPAs | Completely removed |
| ROPC Grant | Available (client handles user's password) | Completely removed |
| Redirect URI | Allowed wildcard matching | Exact string matching — bit-for-bit |
| Refresh Token | Plain bearer token | Must be sender-constrained (mTLS/DPoP) or rotated on every use |
| Bearer Token in URI | Allowed ?access_token=xxx | Prohibited — 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.
| Aspect | OAuth 2.1 | OpenID Connect |
|---|---|---|
| Purpose | Authorization — grant access to resources | Authentication — verify user identity |
| Primary Token | access_token (opaque or JWT) | id_token (always JWT) + access_token |
| User Info | No standard | Standardized /userinfo endpoint |
| Discovery | No standard | .well-known/openid-configuration |
| Scopes | Arbitrary | openid required + profile, email... |
| Session | Not managed | Session 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
| Provider | License | OAuth 2.1 | DPoP | PAR | Best For |
|---|---|---|---|---|---|
| Keycloak 26 | Apache 2.0 | ✅ | ✅ | ✅ | Self-hosted, Java ecosystem, enterprise |
| Duende IdentityServer 7 | Commercial ($) | ✅ | ✅ | ✅ | ASP.NET Core native, full control |
| OpenIddict 6 | Apache 2.0 | ✅ | Partial | ✅ | Lightweight, embedded in ASP.NET Core |
| Microsoft Entra ID | SaaS ($) | ✅ | ✅ | ✅ | Azure ecosystem, enterprise SSO |
| Auth0 / Okta | SaaS (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
- The OAuth 2.1 Authorization Framework — IETF Draft
- OAuth 2.1 Overview — oauth.net
- OAuth 2.1 vs 2.0: Key Differences, Security Changes — Descope
- OAuth 2.1: What's new, what's gone, and how to migrate securely — WorkOS
- OAuth 2.1 vs 2.0: What developers need to know — Stytch
- Configure OpenID Connect in ASP.NET Core — Microsoft Learn
- OAuth 2.1: Key Updates and Differences — FusionAuth
Platform Engineering 2026: Building Effective Internal Developer Platforms
Server-Sent Events — Building a Real-time Dashboard with .NET 10, Vue 3 & Redis
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.