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

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.

RFC draft-15Phiên bản hiện tại của OAuth 2.1 spec
100%PKCE bắt buộc cho mọi Authorization Code flow
0Implicit Flow và ROPC — đã bị loại bỏ
DPoPSender-constrained token — chống token theft

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.

2012 — OAuth 2.0 (RFC 6749)
Ra đời với 4 grant types: Authorization Code, Implicit, ROPC, Client Credentials. Implicit được khuyến nghị cho SPA.
2015 — PKCE (RFC 7636)
Proof Key for Code Exchange — ban đầu chỉ cho mobile apps, sau trở thành best practice cho mọi client.
2017 — OAuth 2.0 for Native Apps (RFC 8252)
Khuyến nghị dùng system browser thay vì embedded WebView cho mobile OAuth.
2021 — OAuth 2.0 Security BCP (RFC 9700)
Chính thức khuyến nghị KHÔNG dùng Implicit Flow. PKCE nên bắt buộc cho mọi client.
2023 — DPoP (RFC 9449)
Demonstrating Proof-of-Possession — bind token với client cụ thể, chống token theft.
2024–2026 — OAuth 2.1 (draft-ietf-oauth-v2-1)
Gộp tất cả RFC trên thành spec thống nhất. PKCE bắt buộc, Implicit và ROPC bị x��a.

2. OAuth 2.1 thay đổi gì so với 2.0?

Thay đổiOAuth 2.0OAuth 2.1
PKCETùy chọn (chỉ khuyến nghị cho public client)Bắt buộc cho MỌI Authorization Code flow
Implicit FlowCó sẵn, phổ biến cho SPABị loại bỏ hoàn toàn
ROPC GrantCó sẵn (client nhận username/password)Bị loại bỏ hoàn toàn
Redirect URICho phép wildcard matchingExact string matching — bit-for-bit
Refresh TokenBearer token đơn thuầnPhải sender-constrained (mTLS/DPoP) hoặc rotate mỗi lần dùng
Bearer Token in URICho phép ?access_token=xxxBị 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ạnhOAuth 2.1OpenID Connect
Mục đíchAuthorization — cấp quyền truy cập resourceAuthentication — xác minh danh tính user
Token chínhaccess_token (opaque hoặc JWT)id_token (luôn là JWT) + access_token
User infoKhông có chuẩn/userinfo endpoint chuẩn
DiscoveryKhông có chuẩn.well-known/openid-configuration
ScopeTùy ýopenid bắt buộc + profile, email...
SessionKhô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

ProviderLicenseOAuth 2.1DPoPPARPhù hợp
Keycloak 26Apache 2.0Self-hosted, Java ecosystem, enterprise
Duende IdentityServer 7Commercial ($)ASP.NET Core native, full control
OpenIddict 6Apache 2.0PartialLightweight, embedded trong ASP.NET Core
Microsoft Entra IDSaaS ($)Azure ecosystem, enterprise SSO
Auth0 / OktaSaaS (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