Bỏ qua để đến nội dung
Knowledge Hub

OAuth 2.0, OIDC và PKCE: đăng nhập qua identity provider mà không để lộ code hay token Trung cấp

Thời gian đọc ước tính: 12 phút

Một shop có SPA ở shop.example, một mobile app và orders API viết bằng NestJS ở api.shop.example. Người dùng đăng nhập qua identity provider (IdP) ở id.shop.example, nên shop không tự lưu mật khẩu.

Bản đầu đi đường tắt. SPA gửi thẳng mật khẩu tới token endpoint của IdP và cất access token trong localStorage. Mobile app đăng nhập trong một WebView nhúng. API nhận mọi token do IdP ký.

Mỗi client nên dùng luồng nào, token nằm ở đâu, và API phải kiểm gì trước khi tin một token?

Tình huống Chọn Điều kiện
SPA có backend riêng BFF + Authorization Code + PKCE; browser chỉ giữ session cookie BFF là confidential client; quy tắc cookie xem Session hay JWT
SPA hoàn toàn không có backend Public client chạy trong browser, Authorization Code + PKCE Kém an toàn nhất theo RFC 10017; access token ngắn, refresh token rotation
Mobile app Authorization Code + PKCE trong system browser Redirect https đã claim; refresh token trong Keychain/Keystore
“User này là ai?” OpenID Connect: scope openid, ID token Client đọc ID token; API không bao giờ nhận nó
Implicit grant hoặc password grant Không dùng RFC 9700: implicit SHOULD NOT, password grant MUST NOT

OAuth 2.0 lo authorization: client lấy access token để gọi API thay mặt người dùng. OpenID Connect (OIDC) thêm authentication lên trên: một ID token cho client biết ai vừa đăng nhập.

ID token (OIDC) Access token (OAuth)
Audience (aud) client_id của client API, ví dụ https://api.shop.example/orders
Ai đọc Client, một lần, lúc login API, ở mỗi request
Trả lời “User là ai, đăng nhập lúc nào?” “Request này có được làm thay sub với scope này không?”
Định dạng Luôn là JWT có chữ ký JWT (RFC 9068, typ: at+jwt) hoặc opaque, tuỳ IdP

Bốn vai trò OAuth trong tình huống này: người dùng (resource owner), SPA hoặc app (client), id.shop.example (authorization server) và orders API (resource server).

Confidential client giữ được secret (một backend). Public client thì không: mọi thứ nằm trong bundle SPA hay file app đều có thể bị trích ra (RFC 6749 §2.1).

Ở mỗi bước ai giữ bí mật nào, để code hay token bị lấy cắp cũng vô dụng?

Authorization Code + PKCE qua BFF: verifier và token không bao giờ tới browser
Authorization Code + PKCE qua BFF: verifier và token không bao giờ tới browserBước 1/9: BFF sinh ngẫu nhiên state, nonce và code_verifier 43 ký tự, lưu vào session tạm trước login, rồi băm verifier.thời gianverifier không rời BFF …Bấm "Đăng nhập"state, nonce, verifierchallenge = S256(v)BrowserSPA, chỉ có cookieApp backend (BFF)confidential clientIdentity providerauthorization serverOrders APIresource server NestJS
Bước 1/9

BFF sinh ngẫu nhiên state, nonce và code_verifier 43 ký tự, lưu vào session tạm trước login, rồi băm verifier.

Bước Thay đổi Chặn được
0–1 BFF lưu state, nonce, verifier; browser chỉ mang challenge Không bí mật nào nằm ở browser
2 Mật khẩu gõ ở IdP; code gắn với challenge App không thấy mật khẩu
3 BFF so state (và iss) với session Login CSRF, IdP mix-up
4 IdP kiểm S256(code_verifier) == code_challenge Code bị lấy cắp thành vô dụng
5 BFF kiểm ID token, cấp cookie __Host- ID token bị replay (nonce), token lọt vào JS
6–7 API kiểm access token Token giả hoặc sai đích
8 Refresh token được rotate Refresh token bị dùng lại

PKCE: chứng minh bên đổi code chính là bên bắt đầu luồng

Phần tiêu đề “PKCE: chứng minh bên đổi code chính là bên bắt đầu luồng”
const verifier = randomBytes(32).toString('base64url'); // 43 ký tự, 256 bit
const challenge = createHash('sha256').update(verifier).digest('base64url'); // method S256

Verifier dài 43–128 ký tự unreserved (RFC 7636). Chỉ challenge đi qua browser; verifier đi thẳng tới token endpoint. Không khớp thì IdP trả invalid_grant.

Luôn gửi code_challenge_method=S256. Bỏ trống thì mặc định là plain: challenge bằng verifier, chặn được một cái là có cả hai.

RFC 9700 §2.1.1: public client MUST dùng PKCE, confidential client được khuyến nghị (RECOMMENDED), và authorization server MUST hỗ trợ.

  • state: giá trị ngẫu nhiên dùng một lần, gắn với session của browser (RFC 6749 §10.12). Thiếu nó, kẻ tấn công nhét code của chính họ vào và bạn bị đăng nhập vào tài khoản của họ (login CSRF).
  • nonce: được IdP chép vào ID token (OIDC Core). Tuỳ chọn trong code flow; nó gắn ID token với đúng lần login này.
  • iss trong callback (RFC 9207): chặn mix-up khi một client dùng nhiều IdP.

RFC 9700 chỉ cho client dựa vào PKCE để chống CSRF sau khi đã xác nhận IdP hỗ trợ PKCE. Giữ thêm state gần như không tốn gì.

RFC 9700 §2.1: IdP MUST so redirect URI bằng so khớp chuỗi tuyệt đối, ngoại lệ duy nhất là port của loopback URI cho native app. Wildcard hay khớp tiền tố cho phép kẻ tấn công lái code tới https://shop.example/callback/../open-redirect.

Grant Vấn đề Trạng thái
Implicit (response_type=token) Access token trả về trong URL: lộ qua history, referrer, có thể bị inject; không PKCE, không sender binding RFC 9700: SHOULD NOT dùng
Resource owner password (ROPC) Client thấy mật khẩu; không có MFA hay consent ở IdP; tập cho người dùng gõ mật khẩu vào bất kỳ app nào RFC 9700: MUST NOT dùng

OAuth 2.1 bỏ cả hai. Nó vẫn là Internet-Draft (draft -16, tháng 9/2026), chưa phải RFC, nhưng các quy tắc đã có sẵn trong RFC 9700.

Client Mô hình Token lộ ra ở đâu
SPA có backend BFF: backend là OAuth client, proxy các lời gọi API Không có trong browser; cookie cần chống CSRF
SPA, backend chỉ đi lấy token Token-mediating backend Access token nằm trong JS
SPA không backend Public client chạy trong browser Access và refresh token, XSS nào cũng đọc được
Mobile app Public client, system browser (RFC 8252) Refresh token trong secure storage của OS

RFC 10017 xếp các mô hình này theo mức an toàn giảm dần và khuyến nghị mạnh BFF cho ứng dụng doanh nghiệp và dữ liệu cá nhân.

RFC 8252 §8.12: native app MUST NOT dùng WebView nhúng để authorize; app chủ có thể đọc mật khẩu và cookie.

API NestJS nhận một bearer token và phải tự quyết, không gọi IdP, có tin nó hay không.

Đang tải diagram…

// orders-api/src/auth/access-token.guard.ts — jose v6
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.IDP_JWKS_URI!)); // jwks_uri lấy từ discovery
@Injectable()
export class AccessTokenGuard implements CanActivate {
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const [scheme, token] = (req.headers.authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token) throw new UnauthorizedException();
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://id.shop.example',
audience: 'https://api.shop.example/orders',
algorithms: ['ES256'],
typ: 'at+jwt',
clockTolerance: 30,
});
req.user = { sub: payload.sub, scope: payload.scope };
return true;
} catch {
throw new UnauthorizedException();
}
}
}
  • aud (RFC 9068 §4, RFC 9700 §2.3): API từ chối token không cấp cho nó. Chính điều này chặn ID token, hay token của API khác, dùng được ở đây.
  • typ: at+jwt chỉ dùng được khi IdP cấp token theo RFC 9068. Token opaque thì phải introspect.
  • Xoay key: createRemoteJWKSet fetch lại khi gặp kid lạ, tối đa một lần mỗi cooldownDuration (mặc định 30 giây); cache mặc định sống 10 phút.
  • Kiểm token chỉ trả lời “ai”; kiểm BOLA trên đơn hàng vẫn là việc của bạn.

Vì sao từng claim quan trọng (alg: none, key confusion): Session hay JWT.

RFC 9700 §2.2.2: refresh token của public client MUST được sender-constrain hoặc rotate. Rotation và phát hiện reuse xem ở Session hay JWT. Với BFF, refresh token không bao giờ rời server.

  • Authorization code: dùng một lần, thời gian sống khuyến nghị ≤ 10 phút (RFC 6749 §4.1.2); code bị dùng lại thì nên thu hồi các token đã cấp từ nó.
  • Request OIDC MUST có scope openid; thiếu nó thì kết quả chỉ là OAuth thường.
  • Redirect URI cho native app: ưu tiên https đã claim; app desktop dùng loopback IP với port bất kỳ (RFC 8252 §7).

Mô phỏng chạy được trong bộ nhớ (IdP, token endpoint, orders API), chỉ dùng node:crypto:

Terminal window
npx --prefix examples ts-node examples/src/oauth2-oidc-pkce/pkce-flow-demo.ts
1. S256(RFC 7636 App. B verifier) = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
matches spec: true
3. Token endpoint
stolen code, attacker has no verifier FAIL invalid_grant (code_verifier mismatch)
replay the first (used) code FAIL invalid_grant (code already used)
redirect_uri + "/../evil" FAIL redirect_uri not registered (exact match)
4. Orders API
access_token ACCEPT sub=user-42 scope=orders:read
id_token sent as Bearer REJECT typ JWT is not an access token
5. Key rotation (new kid 2026-11)
10 s after last JWKS fetch REJECT unknown kid 2026-11
61 s after: unknown kid -> refetch ACCEPT sub=user-42 scope=orders:read

Mục 5 là lý do IdP phải công bố key mới trước khi ký bằng nó: verifier còn trong cooldown sẽ từ chối kid chưa từng thấy.

Authorization Code + PKCE + BFF được gì

  • Mật khẩu chỉ gõ ở IdP; MFA và consent nằm ở đó.
  • Code bị lấy cắp vô dụng nếu không có verifier.
  • Không có token trong browser; XSS không mang token đi được.

Phải trả gì

  • BFF là server có state nằm trên đường request, kèm session và chống CSRF.
  • Nhiều redirect và round trip hơn một form mật khẩu.
  • Phải vận hành xoay key và cache JWKS.
  1. Tắt implicit và password grant cho mọi client ở IdP.
  2. Đăng ký redirect URI khớp tuyệt đối cho từng client; client_id riêng cho SPA/BFF và mobile.
  3. Bắt buộc PKCE với S256; từ chối plain.
  4. Sinh state và nonce mới cho mỗi lần login, gắn với session trước login, dùng một lần.
  5. SPA: để token trong BFF; cookie __Host-, Secure, HttpOnly, SameSite.
  6. Mobile: system browser, redirect https đã claim, refresh token trong Keychain/Keystore.
  7. API: kiểm alg, kid qua JWKS, iss, aud, exp, typ; từ chối ID token.
  8. Bật refresh token rotation có phát hiện reuse; access token sống vài phút.

Lỗi phía API gây hại nhất là chỉ kiểm chữ ký:

Nhận mọi token do IdP ký

Hay gặp

await jwtVerify(token, JWKS); // không issuer, audience, typ

Nên viết

await jwtVerify(token, JWKS, { issuer, audience: ORDERS_API, algorithms: ['ES256'], typ: 'at+jwt' });

Vì sao

IdP thường ký ID token và token cho các API khác bằng cùng bộ key. Không kiểm aud thì token nào trong số đó cũng mở được orders API.

Bẫy thứ hai nằm ở client, không phải API:

Cách hiểu sai Hệ quả Cần làm
PKCE thay được client secret BFF không xác thực client thành public client Confidential client dùng cả hai
Có PKCE thì bỏ state được Mất chống CSRF nếu IdP bỏ qua PKCE Giữ state
  1. Kẻ tấn công chặn được code trên một custom URL scheme. Bước nào thất bại với họ, và dùng plain thì câu trả lời đổi ra sao?
  2. Vì sao API vẫn từ chối ID token dù nó được ký bằng đúng key của IdP?
  3. IdP thêm key và ký bằng nó ngay trong cùng giây. Vì sao vài instance API có thể tạm từ chối token hợp lệ, và thứ tự xoay key nào tránh được?
  • RFC 6749: vai trò, loại client, thời gian sống của code, CSRF.
  • RFC 7636: định dạng verifier PKCE và S256.
  • RFC 9700: danh sách MUST/SHOULD bảo mật OAuth hiện hành.
  • OpenID Connect Core 1.0: cách kiểm ID token.
  • RFC 10017: BFF và các kiến trúc browser khác.
  • RFC 8252: native app, system browser, redirect URI.
  • RFC 9068: resource server phải kiểm gì với JWT access token.
  • OAuth 2.1 draft: trạng thái hiện tại.
  • jose: tuỳ chọn jwtVerify, cache JWKS.