OAuth 2.1 và OpenID Connect: Xác thực API đúng cách năm 2026
Posted on: 4/21/2026 7:19:13 PM
Table of contents
- 1. OAuth 2.0 đã lỗi thời — Tại sao cần 2.1?
- 2. OAuth 2.1 thay đổi gì so với 2.0?
- 3. PKCE — Vì sao bắt buộc cho mọi client?
- 4. DPoP — Sender-Constrained Token
- 5. OpenID Connect — Lớp Identity bên trên OAuth
- 6. BFF Pattern — Cách đúng để SPA dùng OAuth 2.1
- 7. Pushed Authorization Requests (PAR)
- 8. Triển khai trên ASP.NET Core .NET 10
- 9. So sánh Identity Provider cho .NET 2026
- 10. Security Checklist cho OAuth 2.1
- Nguồn tham khảo
Nếu bạn đang dùng OAuth 2.0 với Implicit Flow, hoặc cho phép client gửi username/password trực tiếp qua Resource Owner Password Credentials (ROPC) — thì hệ thống của bạn đang dùng những pattern đã bị chính thức khai tử. OAuth 2.1 không phải bản nâng cấp nhỏ — nó là sự hợp nhất của OAuth 2.0 core cùng hàng loạt RFC bảo mật quan trọng thành một specification duy nhất, loại bỏ mọi thứ không an toàn.
Bài viết này đi sâu vào những thay đổi cốt lõi trong OAuth 2.1, cách OpenID Connect bổ sung lớp identity, và hướng dẫn triển khai trên ASP.NET Core .NET 10 với các pattern mới nhất: PKCE bắt buộc, DPoP, Pushed Authorization Requests (PAR), và BFF cho SPA.
1. OAuth 2.0 đã lỗi thời — Tại sao cần 2.1?
OAuth 2.0 (RFC 6749) ra đời năm 2012 — thời mà SPA chưa phổ biến, mobile app còn sơ khai, và threat model đơn giản hơn nhiều. Qua 14 năm, cộng đồng đã phát hiện hàng loạt lỗ hổng và publish nhiều RFC bổ sung (PKCE, Browser-Based Apps BCP, Security BCP, Native Apps BCP...). Vấn đề: developer phải đọc 7+ RFC riêng lẻ để hiểu "cách làm đúng".
OAuth 2.1 giải quyết bằng cách gộp tất cả best practices vào một document duy nhất và loại bỏ những gì không an toàn.
2. OAuth 2.1 thay đổi gì so với 2.0?
| Thay đổi | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | Tùy chọn (chỉ khuyến nghị cho public client) | Bắt buộc cho MỌI Authorization Code flow |
| Implicit Flow | Có sẵn, phổ biến cho SPA | Bị loại bỏ hoàn toàn |
| ROPC Grant | Có sẵn (client nhận username/password) | Bị loại bỏ hoàn toàn |
| Redirect URI | Cho phép wildcard matching | Exact string matching — bit-for-bit |
| Refresh Token | Bearer token đơn thuần | Phải sender-constrained (mTLS/DPoP) hoặc rotate mỗi lần dùng |
| Bearer Token in URI | Cho phép ?access_token=xxx | Bị cấm — chỉ dùng Authorization header |
⚠️ Breaking changes thực tế
Nếu SPA của bạn đang dùng Implicit Flow (response_type=token), bạn phải migrate sang Authorization Code + PKCE. Nếu mobile app đang dùng ROPC (gửi username/password trực tiếp), phải chuyển sang system browser + Authorization Code. Đây không phải khuyến nghị — đây là bắt buộc trong OAuth 2.1.
3. PKCE — Vì sao bắt buộc cho mọi client?
PKCE (Proof Key for Code Exchange, đọc là "pixie") ban đầu được thiết kế để bảo vệ mobile apps khỏi authorization code interception attack. Nhưng threat model đã mở rộng: ngay cả confidential client (server-side) cũng có thể bị tấn công qua cross-site request forgery hoặc code injection nếu không dùng PKCE.
sequenceDiagram
participant App as Client App
participant AS as Authorization Server
participant RS as Resource Server
Note over App: Tạo code_verifier (random 43-128 chars)
Note over App: Tính 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 với 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 không bao giờ rời client cho đến bước token exchange
Cơ chế bảo vệ: Ngay cả khi attacker chặn được authorization_code (qua phishing redirect, malicious app trên cùng device...), họ không thể exchange nó thành access token vì không có code_verifier — thứ chỉ tồn tại trong bộ nhớ của client app gốc.
4. DPoP — Sender-Constrained Token
Bearer token có một weakness cố hữu: ai có token là có quyền. Nếu token bị đánh cắp (qua log, proxy, XSS...), attacker dùng được ngay. DPoP (Demonstrating Proof-of-Possession) giải quyết bằng cách bind token với một cặp key riêng của client.
sequenceDiagram
participant C as Client
participant AS as Auth Server
participant RS as Resource Server
Note over C: Tạo asymmetric keypair (một lần)
C->>AS: POST /token + DPoP proof JWT
(signed bằng private key)
AS-->>C: access_token (có cnf.jkt claim = hash of public key)
C->>RS: GET /api/data
Authorization: DPoP {access_token}
DPoP: {proof JWT mới, signed bằng 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 có access_token nhưng KHÔNG có private key
Note over C,RS: → Không tạo được DPoP proof → Request bị reject
DPoP: token bị đánh cắp trở nên vô dụng nếu không có private key của client
✅ Khi nào dùng DPoP?
DPoP đặc biệt quan trọng cho: (1) API có quyền cao (financial, healthcare), (2) Môi trường có risk token leakage cao (mobile, SPA), (3) Khi compliance yêu cầu sender-constrained tokens (FAPI 2.0, Open Banking). Với internal microservices, mTLS thường là lựa chọn đơn giản hơn.
5. OpenID Connect — Lớp Identity bên trên OAuth
OAuth 2.1 chỉ giải quyết authorization (ai được làm gì). Nhưng đa số ứng dụng cần authentication (người dùng này là ai). Đây là vai trò của OpenID Connect (OIDC) — một lớp identity protocol xây trên OAuth 2.0/2.1.
| Khía cạnh | OAuth 2.1 | OpenID Connect |
|---|---|---|
| Mục đích | Authorization — cấp quyền truy cập resource | Authentication — xác minh danh tính user |
| Token chính | access_token (opaque hoặc JWT) | id_token (luôn là JWT) + access_token |
| User info | Không có chuẩn | /userinfo endpoint chuẩn |
| Discovery | Không có chuẩn | .well-known/openid-configuration |
| Scope | Tùy ý | openid bắt buộc + profile, email... |
| Session | Không quản lý | 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 nằm trên OAuth 2.1, bổ sung authentication và identity
6. BFF Pattern — Cách đúng để SPA dùng OAuth 2.1
Sau khi Implicit Flow b�� loại bỏ, câu hỏi lớn nhất là: SPA xử lý authentication thế nào? Câu trả lời năm 2026: Backend for Frontend (BFF) pattern.
Thay vì SPA (JavaScript chạy trên browser) trực tiếp giữ access token — nơi token có thể bị đánh cắp qua XSS — BFF đẩy toàn bộ OAuth flow sang một backend nhẹ. SPA chỉ dùng HttpOnly cookie (không thể đọc bằng 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: Lưu tokens trong server-side session
Trả HttpOnly cookie cho SPA
BFF-->>SPA: Set-Cookie: .bff-session (HttpOnly, Secure, SameSite)
SPA->>BFF: GET /bff/api/orders (cookie tự động gửi)
BFF->>API: GET /api/orders
Authorization: Bearer {access_token}
API-->>BFF: 200 OK + data
BFF-->>SPA: 200 OK + data
BFF Pattern: SPA không bao giờ chạm vào token — mọi thứ qua HttpOnly cookie
🔐 Tại sao không dùng Authorization Code + PKCE trực tiếp trong SPA?
Về kỹ thuật, SPA có thể dùng Authorization Code + PKCE (đây là public client flow). Nhưng access token sẽ nằm trong JavaScript memory hoặc sessionStorage — vẫn có thể bị XSS đánh cắp. BFF pattern loại bỏ hoàn toàn risk này vì token chỉ tồn tại server-side. Đây là khuyến nghị chính thức của OAuth 2.0 for Browser-Based Apps BCP và được Microsoft, Auth0, Duende áp dụng.
7. Pushed Authorization Requests (PAR)
PAR (RFC 9126) là một cải tiến quan trọng: thay vì đặt tất cả OAuth parameters lên URL (query string), client gửi chúng qua POST đến authorization server trước, nhận về một request_uri, rồi chỉ gửi URI đó lên browser redirect.
// Bước 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
}
// Bước 2: Browser redirect (chỉ có request_uri)
GET /authorize?client_id=my-app&request_uri=urn:ietf:params:oauth:request_uri:abc123
Lợi ích: (1) Parameters không lộ trên URL/browser history, (2) Request được authenticated (chứng minh client hợp lệ trước khi user thấy consent screen), (3) Tránh URL length limits với complex requests. ASP.NET Core .NET 10 bật PAR mặc định khi identity provider hỗ trợ.
8. Triển khai trên ASP.NET Core .NET 10
8.1. Cấu hình OIDC Client (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 — bắt buộc trong 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; // Giữ claim names gốc
// PAR — .NET 10 tự bật khi server hỗ trợ
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 cho Vue.js SPA
// BFF endpoints — proxy API calls với 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 — Validate JWT
// 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 với 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. So sánh Identity Provider cho .NET 2026
| Provider | License | OAuth 2.1 | DPoP | PAR | Phù hợp |
|---|---|---|---|---|---|
| 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 trong ASP.NET Core |
| Microsoft Entra ID | SaaS ($) | ✅ | ✅ | ✅ | Azure ecosystem, enterprise SSO |
| Auth0 / Okta | SaaS (free tier) | ✅ | ✅ | ✅ | Quick start, managed service |
✅ Khuyến nghị cho team nhỏ
Nếu bạn đã dùng Azure → Microsoft Entra ID (tích hợp sẵn). Nếu cần self-host miễn phí → Keycloak (mature, đầy đủ tính năng). Nếu muốn embed trực tiếp trong ASP.NET Core app → OpenIddict (nhẹ, linh hoạt). Duende IdentityServer phù hợp khi cần full customization và sẵn sàng trả license fee.
10. Security Checklist cho OAuth 2.1
📋 Authorization Server
☐ PKCE bắt buộc cho mọi Authorization Code flow
☐ Implicit Flow và ROPC bị disable hoàn toàn
☐ Redirect URI exact matching — không wildcard
☐ Refresh token rotation hoặc sender-constrained (DPoP/mTLS)
☐ Access token lifetime ngắn (5–15 phút)
☐ Hỗ trợ PAR (Pushed Authorization Requests)
☐ Token introspection endpoint cho resource servers
☐ Rate limiting trên /token và /authorize endpoints
📋 Client Application
☐ Luôn dùng Authorization Code + PKCE
☐ SPA: dùng BFF pattern — không giữ token trong browser
☐ Mobile: dùng system browser (không embedded WebView)
☐ Validate id_token: issuer, audience, expiry, nonce
☐ DPoP cho API có quyền cao
☐ Không lưu token trong localStorage (dùng HttpOnly cookie hoặc memory)
☐ Implement token refresh trước khi access_token hết hạn
☐ Handle 401 gracefully — redirect về login
📋 Resource API
☐ Validate JWT: signature, issuer, audience, expiry
☐ Clock skew tolerance ≤ 30 giây
☐ Kiểm tra scope/permission trước khi trả data
☐ Không tin vào client-side claims cho authorization decisions
☐ Log authentication failures để detect brute force
☐ CORS chỉ cho phép origin đã đăng ký
OAuth 2.1 không phải "thêm tính năng mới" — nó là việc loại bỏ những gì sai và biến best practices thành bắt buộc. Nếu bạn đang build hệ thống mới năm 2026, hãy bắt đầu với OAuth 2.1 + OIDC + PKCE + BFF pattern. Nếu bạn đang maintain hệ thống cũ, ưu tiên migrate khỏi Implicit Flow và bật PKCE — đó là hai thay đổi có impact bảo mật lớn nhất với effort thấp nhất.
Nguồn tham khảo
- 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: Xây Dựng Internal Developer Platform Hiệu Quả
Server-Sent Events — Xây dựng Real-time Dashboard với .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.