blogbyAndrew

3-Legged OAuth (3LO) Deep Dive: Từ cơ bản đến thực chiến

April 22, 2026

Hình ảnh ổ khoá số tượng trưng cho uỷ quyền và bảo mật trong OAuth

Vì sao chúng ta cần 3-Legged OAuth?

Hãy tưởng tượng bạn đang xây một ứng dụng tên là PhotoPrint — dịch vụ in ảnh. Để tiện cho người dùng, bạn muốn họ chọn thẳng ảnh từ Google Drive thay vì phải tải về máy rồi upload lại. Vậy PhotoPrint cần quyền đọc ảnh trong Drive của người dùng.

Cách làm ngây thơ — và cực kỳ tệ — là hỏi thẳng username + password Google của họ. Vừa sai về bảo mật, vừa không scale: nếu Google bật 2FA, hoặc người dùng đổi password, ứng dụng của bạn chết. Chưa kể việc lưu password của người dùng là một quả bom nổ chậm — rò rỉ database là mất credentials Google của tất cả khách hàng.

Vấn đề cốt lõi ở đây là delegated authorization (uỷ quyền): làm sao để người dùng có thể nói với Google rằng "hãy cho PhotoPrint đọc ảnh của tôi, nhưng đừng cho nó làm gì khác, và tôi có thể thu hồi quyền này bất cứ lúc nào" — mà không phải đưa password cho PhotoPrint.

Đây chính xác là vấn đề mà OAuth 2.0 ra đời để giải quyết. Và khi flow có sự tham gia của người dùng cuối (không phải chỉ hai máy chủ giao tiếp với nhau), chúng ta gọi đó là 3-Legged OAuth — viết tắt 3LO.

3-Legged OAuth (3LO): Flow OAuth có ba bên (three legs) tham gia — người dùng cuối, ứng dụng khách (client), và authorization server. Người dùng đăng nhập trực tiếp vào authorization server để uỷ quyền cho client, không bao giờ chia sẻ password với client. Tên "legs" ám chỉ ba đoạn tương tác riêng biệt trong flow.

2-Legged OAuth (2LO): Flow không có người dùng cuối — client tự xác thực với authorization server bằng credentials của chính nó (client_id + client_secret) để truy cập dữ liệu do chính nó sở hữu, không phải dữ liệu của user cụ thể nào. Điển hình dùng cho machine-to-machine (M2M): một backend service gọi API của service khác.

Sự khác biệt quan trọng nhất giữa 2LO và 3LO là consent screen: 3LO luôn có một bước người dùng thấy trang "App X wants to access: Read your photos" và bấm Allow hoặc Deny. Không có consent screen, không phải 3LO.

Bài này sẽ đi đến đâu

3LO không chỉ là "redirect user đến Google rồi lấy token". Nó là cả một hệ thống được thiết kế để chống lại hàng loạt lớp tấn công — và nếu triển khai sai một chi tiết nhỏ, toàn bộ hệ thống có thể đổ vỡ. Chúng ta sẽ đi qua các lớp đó từ ngoài vào trong:

text
Roadmap:

  [Foundations]
  1. OAuth 2.0 roles, grant types, 2LO vs 3LO

  [The canonical flow]
  2. Authorization Code Flow - step by step
  3. Parameters: client_id, redirect_uri, scope, state

  [Security hardening]
  4. PKCE - why "public clients" need extra armor
  5. Tokens: access, refresh, ID token (OIDC)
  6. Security considerations: CSRF, redirect URI, token storage

  [Practice]
  7. Implementation walkthrough (Go backend + SPA)
  8. Common pitfalls and anti-patterns

  [Wrap up]
  9. Conclusion and next steps (OAuth 2.1, OIDC, DPoP)

Mục tiêu là sau khi đọc xong, bạn có thể tự đánh giá một triển khai OAuth bất kỳ — từ Google Sign-In đến một vendor nội bộ — và nói chính xác chỗ nào đúng, chỗ nào sai, và tại sao.

Nền tảng OAuth 2.0

Trước khi đi sâu vào Authorization Code Flow, cần nắm rõ ai là ai trong OAuth. Spec (RFC 6749) định nghĩa bốn vai trò — và bốn cái tên này sẽ quay lại xuyên suốt bài viết, nên hãy đọc kỹ phần này.

Bốn vai trò

Resource Owner: Chủ sở hữu dữ liệu — thường là người dùng cuối (end user). Trong ví dụ PhotoPrint, resource owner là người có tài khoản Google và ảnh trong Drive. Chỉ resource owner mới có quyền phê duyệt (consent) việc cấp quyền truy cập.

Client: Ứng dụng muốn truy cập dữ liệu thay mặt resource owner. Có thể là web app (PhotoPrint), mobile app, SPA, hoặc backend service. OAuth chia client làm hai loại: confidential (có thể giữ bí mật, như backend) và public (không thể giữ bí mật, như SPA hoặc mobile — vì code chạy trên máy user).

Authorization Server (AS): Máy chủ phát hành token. Nó xác thực user, hiển thị consent screen, và cấp authorization code / access token. Trong ví dụ, AS là accounts.google.com.

Resource Server (RS): Máy chủ giữ dữ liệu được bảo vệ — chấp nhận access token và trả dữ liệu về. Trong ví dụ, RS là API Google Drive (www.googleapis.com/drive/...). AS và RS có thể là cùng một công ty nhưng thường là hai hệ thống tách biệt.

Một điểm dễ nhầm: authorization server xử lý cấp quyền, còn resource server xử lý dữ liệu. Access token là "vé" mà AS phát, và RS kiểm tra.

Kiến trúc tổng quan

text
  +------------------------------------------------------------+
  |                                                            |
  |                     +------------------+                   |
  |                     |  Resource Owner  |                   |
  |                     |     (user)       |                   |
  |                     +--------+---------+                   |
  |                              |                             |
  |                              | 1. Grant authorization      |
  |                              | (via consent screen)        |
  |                              v                             |
  |  +-----------+      +--------+---+       +---------------+ |
  |  |           | auth |            |  get  |               | |
  |  |  Client   +----->+    Auth    +<------+   Resource    | |
  |  |  (app)    | code | Server (AS)| token |   Server (RS) | |
  |  |           |      |            | verify|               | |
  |  +-----+-----+      +------------+       +-------+-------+ |
  |        |                                         ^         |
  |        |       4. Call API with access token     |         |
  |        +-----------------------------------------+         |
  |                                                            |
  +------------------------------------------------------------+

Điều cực kỳ quan trọng: user chỉ đăng nhập ở AS, không bao giờ ở Client. Client không thấy password của user. Nó chỉ thấy một token mà AS đưa cho — và token đó có scope giới hạn, có thời hạn, có thể thu hồi.

Các grant type chính

OAuth 2.0 định nghĩa nhiều "grant type" — mỗi cái là một cách lấy token khác nhau cho các tình huống khác nhau:

Grant typeNgười dùng tham gia?Dùng khi nàoTrạng thái
Authorization CodeCó (3LO)Web app, mobile, SPAKhuyên dùng
Authorization Code + PKCECó (3LO)Public client (SPA, mobile, native)Bắt buộc với public client
ImplicitCó (3LO)SPA (trước đây)Deprecated — không dùng
Resource Owner PasswordCó (user đưa password cho client)LegacyDeprecated
Client CredentialsKhông (2LO)Machine-to-machineĐúng use case
Refresh TokenKhông (chỉ refresh)Làm mới access tokenDùng kèm các grant trên
Device CodeCó (3LO)Thiết bị không có browser (TV, CLI)Đúng use case

Với 3LO hiện đại, chỉ có hai grant type đáng dùng:

Implicit flow và Resource Owner Password đã bị OAuth 2.1 loại bỏ hoàn toàn. Chúng ta sẽ thấy vì sao ở section Security.

2LO vs 3LO: khi nào dùng cái nào

Nhiều người nhầm lẫn 2LO và 3LO, nhưng câu hỏi đơn giản để phân biệt:

Dữ liệu thuộc về ai — user hay ứng dụng?

Nếu dữ liệu thuộc về user (ảnh của user trong Drive, email của user trong Gmail, repos của user trên GitHub) → 3LO. User phải consent.

Nếu dữ liệu thuộc về ứng dụng (server A cần đọc config của chính nó từ server B, cron job đồng bộ dữ liệu nội bộ giữa hai service của cùng công ty) → 2LO. Không cần user.

text
  +---------------------------------------+
  |            2LO vs 3LO                 |
  +-----------------+---------------------+
  |       2LO       |        3LO          |
  +-----------------+---------------------+
  | Machine-to-     | User-to-service     |
  | machine         |                     |
  |                 |                     |
  | No user         | User logs in at AS  |
  |                 | and clicks "Allow"  |
  |                 |                     |
  | Grant:          | Grant:              |
  | client_creds    | authorization_code  |
  |                 | (+ PKCE)            |
  |                 |                     |
  | Use: internal   | Use: 3rd-party      |
  | backend sync,   | integrations,       |
  | service mesh    | social login,       |
  |                 | SaaS connectors     |
  +-----------------+---------------------+

Phần còn lại của bài sẽ tập trung vào 3LO với Authorization Code + PKCE — flow phổ biến nhất và cũng phức tạp nhất. Nếu nắm được nó, các flow khác chỉ là biến thể.

Authorization Code Flow — từng bước một

Đây là flow chính của 3LO. Spec nằm ở RFC 6749 section 4.1. Nếu chỉ được nhớ một flow duy nhất trong OAuth, nhớ cái này.

Ý tưởng lõi: client không bao giờ nhận trực tiếp access token qua browser. Thay vào đó, nó nhận một authorization code — một chuỗi ngắn, dùng một lần — và sau đó đổi code đó lấy access token qua một request backend (server-to-server). Lý do sẽ rõ ở section PKCE.

Sơ đồ flow

text
  User          Client (PhotoPrint)       Auth Server (Google)      Resource Server
   |                   |                          |                        |
   | 1. Click "Login"  |                          |                        |
   |------------------>|                          |                        |
   |                   |                          |                        |
   |                   | 2. Redirect to AS with   |                        |
   |                   |    client_id, redirect,  |                        |
   |                   |    scope, state          |                        |
   |                   |                          |                        |
   |          3. Browser follows redirect         |                        |
   |<---------------------------------------------|                        |
   |                                              |                        |
   | 4. Login + see consent screen                |                        |
   |--------------------------------------------->|                        |
   |                                              |                        |
   | 5. Click "Allow"                             |                        |
   |--------------------------------------------->|                        |
   |                                              |                        |
   |           6. Redirect back to client with    |                        |
   |              authorization code + state      |                        |
   |<---------------------------------------------|                        |
   |                                              |                        |
   |                   |<-------------------------|                        |
   |                   |                          |                        |
   |                   | 7. POST /token           |                        |
   |                   |    code + client_secret  |                        |
   |                   |------------------------->|                        |
   |                   |                          |                        |
   |                   | 8. access_token,         |                        |
   |                   |    refresh_token         |                        |
   |                   |<-------------------------|                        |
   |                   |                          |                        |
   |                   | 9. GET /drive/files      |                        |
   |                   |    Authorization: Bearer <token>                  |
   |                   |-------------------------------------------------->|
   |                   |                          |                        |
   |                   | 10. Photo data           |                        |
   |                   |<--------------------------------------------------|

Tám bước chia thành hai đoạn:

Thiết kế này tách nhận dạng user (front-channel, cần browser) khỏi trao đổi credentials bí mật (back-channel, không ai nghe lén được). Đây là lý do flow có bước trung gian "authorization code" — để biên giới hai kênh.

Bước 1-2: Authorization Request

Client redirect browser của user đến endpoint /authorize của AS với các tham số:

text
https://accounts.google.com/o/oauth2/v2/auth
  ?response_type=code
  &client_id=123456-abc.apps.googleusercontent.com
  &redirect_uri=https://photoprint.app/oauth/callback
  &scope=https://www.googleapis.com/auth/drive.readonly
  &state=xyz789randomvalue
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

Từng tham số có vai trò riêng:

response_type: Loại response mong muốn. Với Authorization Code Flow, giá trị luôn là code. (Giá trị token là Implicit flow — đã deprecated, đừng dùng.)

client_id: ID định danh của client, được AS cấp khi đăng ký ứng dụng. Giá trị public — ai cũng có thể nhìn thấy trong URL. Bản thân client_id không phải bí mật; việc xác thực client là client_secret (cho confidential client) hoặc PKCE (cho public client).

redirect_uri: URL mà AS sẽ redirect browser về sau khi user đồng ý. Bắt buộc phải trùng chính xác với một URI đã đăng ký trước ở AS — đây là một trong những lớp bảo mật quan trọng nhất (xem section Security).

scope: Danh sách quyền mà client xin. Cách phân tách tuỳ provider — Google dùng space-separated URL, GitHub dùng space-separated keyword. Scope xuất hiện trên consent screen để user duyệt.

state: Giá trị ngẫu nhiên do client tạo, sẽ được AS trả về nguyên văn ở bước callback. Dùng để chống CSRFgiữ context của user. Bắt buộc dùng — sẽ phân tích kỹ ở section Security.

code_challenge / code_challenge_method: Thuộc về PKCE. Giải thích ở section 4.

AS hiển thị form đăng nhập (nếu user chưa đăng nhập) rồi đến consent screen: "PhotoPrint wants to: View your Google Drive files. Allow / Deny?"

Nếu user bấm Allow, AS tạo một authorization code — chuỗi ngẫu nhiên ngắn, thời hạn thường chỉ 30–60 giây, và dùng được đúng một lần — rồi redirect browser về redirect_uri kèm code và state:

text
https://photoprint.app/oauth/callback
  ?code=4/0AX4XfWj...long_random_string
  &state=xyz789randomvalue

Client phải kiểm tra state trả về có khớp với giá trị đã gửi đi hay không. Không khớp → huỷ flow (có thể là CSRF attack).

Bước 7-8: Token exchange

Đây là phần "ma thuật" của Authorization Code Flow. Client backend (không phải browser) gửi POST request đến endpoint /token của AS:

text
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=4/0AX4XfWj...
&redirect_uri=https://photoprint.app/oauth/callback
&client_id=123456-abc.apps.googleusercontent.com
&client_secret=GOCSPX-abc123xyz               <-- confidential client only
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

AS kiểm tra ba thứ:

  1. Code hợp lệ — tồn tại, chưa hết hạn, chưa được đổi.
  2. Client đúng — client_id khớp với code, client_secret đúng (nếu là confidential client).
  3. PKCE đúng — code_verifier băm ra đúng code_challenge đã gửi ở bước 2.

Nếu mọi thứ ok, AS trả về JSON:

json
{
  "access_token": "ya29.a0AfH6...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "1//0gKJ3k...",
  "scope": "https://www.googleapis.com/auth/drive.readonly",
  "id_token": "eyJhbGciOi..."       // optional — chỉ có nếu scope chứa openid
}

Lưu ý: access_token là thứ để gọi API. refresh_token là thứ để lấy access_token mới khi cái cũ hết hạn — không phải để gọi API trực tiếp. ID token là thứ của OpenID Connect (OIDC), không phải OAuth thuần. Section Tokens sẽ giải thích chi tiết.

Bước 9-10: Gọi API

Client gắn access_token vào header Authorization khi gọi resource server:

text
GET https://www.googleapis.com/drive/v3/files
Authorization: Bearer ya29.a0AfH6...

Resource server verify token (thường bằng cách gọi AS hoặc verify JWT signature nội tại) rồi trả dữ liệu. Token không hợp lệ → 401 Unauthorized. Token không đủ scope → 403 Forbidden.

Tại sao không giao thẳng access token ở bước 6?

Câu hỏi hay. Vì sao không để AS redirect thẳng về client với access_token trong URL, thay vì cái vòng vèo "code rồi đổi code lấy token"?

Câu trả lời: URL không an toàn cho secret. Browser history, server access log, HTTP Referer header, extensions — tất cả đều có thể log URL. Nếu access_token nằm trong URL, bất kỳ thành phần nào trong chain đó rò rỉ đều là lộ token (và token có thể dùng để gọi API trực tiếp trong cả giờ đồng hồ).

Authorization code thì ngắn hạn (vài chục giây), chỉ dùng một lần, và cần client_secret hoặc PKCE để đổi — rò rỉ code gần như vô hại. Access token thì ngược lại: thời hạn dài, dùng được trực tiếp.

Đó chính là lý do Implicit Flow (trả access token thẳng qua URL fragment) bị deprecated. Authorization Code Flow tách biệt rõ ràng: URL chỉ chứa "vé đổi token", token thật đi qua kênh server-to-server.

PKCE — Proof Key for Code Exchange

Authorization Code Flow gốc giả định client có thể giữ client_secret bí mật. Với backend server, giả định này đúng — code chạy trên máy chủ, không ai tiếp cận được file env. Nhưng với public client (SPA, mobile app, native app), giả định này sụp đổ hoàn toàn:

Nếu không có client_secret, điều gì ngăn một app độc hại chặn authorization code của user rồi tự đổi code lấy token? Vấn đề này gọi là authorization code injection/interception attack, và giải pháp là PKCE (đọc là "pixy").

PKCE (Proof Key for Code Exchange): Cơ chế bổ sung cho Authorization Code Flow, chuẩn hoá ở RFC 7636. Client tạo một "bí mật dùng một lần" (code_verifier) ngay trước khi bắt đầu flow, gửi bản băm của nó (code_challenge) lên AS ở bước authorize, rồi gửi bản gốc ở bước token exchange. AS kiểm tra hash trùng khớp — nếu kẻ tấn công chặn được code, chúng không có verifier nên không đổi được code lấy token.

Attack mà PKCE chống được

Tình huống trên mobile: user có hai app cài cùng máy — một app hợp pháp "MyBank" và một app độc hại "EvilApp" đã đăng ký cùng custom URL scheme com.mybank://callback với OS. Khi MyBank khởi động flow OAuth:

text
Without PKCE:

  1. MyBank -> AS: authorize request (client_id, redirect_uri)
  2. User logs in at AS, approves
  3. AS -> OS: redirect to com.mybank://callback?code=ABC
  4. OS -> ??? : which app registered this scheme? <-- race!
  5. EvilApp wins the race, captures code=ABC
  6. EvilApp -> AS: POST /token with code=ABC + client_id
  7. AS -> EvilApp: access_token
  8. EvilApp now has user's bank data. Game over.

Bước 7 thành công vì AS chỉ kiểm tra client_id — mà client_id là public, EvilApp biết. Không có cách nào AS phân biệt được request đến từ MyBank thật hay EvilApp giả.

PKCE chèn một bí mật phụ thuộc session vào flow:

text
With PKCE:

  1. MyBank generates random code_verifier (kept in memory)
  2. MyBank computes code_challenge = SHA256(code_verifier)
  3. MyBank -> AS: authorize request (+ code_challenge)
  4. AS stores code_challenge with the issued code
  5. User logs in, approves
  6. AS -> OS: redirect with code=ABC
  7. EvilApp intercepts code=ABC
  8. EvilApp -> AS: POST /token with code=ABC + (what code_verifier??)
  9. EvilApp doesn't have the verifier. Request fails.

Điểm mấu chốt: code_verifier chỉ tồn tại trong memory của MyBank — nó chưa từng đi ra ngoài qua bất kỳ kênh nào mà EvilApp có thể chặn. Code_challenge đi qua AS nhưng nó là hash; EvilApp không đảo ngược được SHA-256.

Cơ chế cụ thể

Client tạo code_verifier — chuỗi ngẫu nhiên 43–128 ký tự, URL-safe:

text
code_verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
                 (43+ chars from [A-Z] [a-z] [0-9] - . _ ~)

Rồi tính code_challenge theo một trong hai method:

text
Verifier:   dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
            |
            | SHA-256
            v
Hash bytes: 0x13 0xdc 0x9a ... (32 bytes)
            |
            | base64url encode, no padding
            v
Challenge:  E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

PKCE trong flow

text
  CLIENT                   AUTH SERVER    STORE
   |                            |             |
   | 1. Generate verifier       |             |
   |    challenge = hash(v)     |             |
   |                            |             |
   | 2. GET /authorize?         |             |
   |    challenge=H             |             |
   |--------------------------->|             |
   |                            |  save H     |
   |                            |------------>|
   |                            |             |
   |         ...consent...      |             |
   |                            |             |
   | 3. redirect back w/ code   |             |
   |<---------------------------|             |
   |                            |             |
   | 4. POST /token             |             |
   |    code + verifier=V       |             |
   |--------------------------->|             |
   |                            |  read H     |
   |                            |<------------|
   |                            |             |
   |                            |  check      |
   |                            |  hash(V)==H |
   |                            |             |
   | 5. access_token            |             |
   |<---------------------------|             |

Ba chi tiết quan trọng cần nhớ:

  1. Verifier chỉ tồn tại trong memory của client giữa bước 1 và bước 4. Với SPA, thường lưu ở sessionStorage (cùng tab) hoặc memory. Không bao giờ log, không bao giờ gửi analytics.
  2. Mỗi flow tạo verifier mới. Tái sử dụng verifier phá hỏng mục đích.
  3. S256 là bắt buộc với public client. plain có trong spec chỉ cho tương thích ngược.

Confidential client có cần PKCE không?

Có. OAuth 2.1 (bản cập nhật đang hoàn thiện) yêu cầu PKCE cho mọi client, không chỉ public. Lý do: PKCE chống được cả authorization code injection — một attack mà confidential client cũng bị, trong đó attacker có code của nạn nhân và cố inject vào session riêng của mình để đăng nhập thành user khác.

Best practice hiện tại: luôn dùng PKCE, kể cả khi client có client_secret. Chi phí thêm là vài dòng code và 0 request; lợi ích là miễn nhiễm với cả một họ attack.

Tokens — access, refresh, ID

Cuối Authorization Code Flow, client nhận được một JSON với (có thể) ba token khác nhau. Nhiều người gộp chúng làm một rồi dùng sai. Ba token này phục vụ ba mục đích hoàn toàn khác nhau.

Access token

Access Token: Credential mà client đính kèm vào mọi request gọi resource server để chứng minh quyền truy cập. Là "vé" dùng để gọi API. Thời hạn ngắn (thường 15 phút đến 1 giờ). Nếu bị lộ, kẻ tấn công có thể gọi API của user trong khoảng thời gian còn lại của token — nên phải bảo vệ nghiêm ngặt.

Format access token không được chuẩn hoá trong OAuth 2.0 — spec coi nó là "opaque" (không minh bạch), client không được phép phán đoán nội dung. Trong thực tế có hai dạng:

JWT nhìn như vậy:

text
eyJhbGciOiJSUzI1NiIsImtpZCI6Im...  .  eyJpc3MiOiJodHRwczovL2FjY29...  .  NQiL4GBKo1V...
|-------- Header --------|          |---------- Payload ----------|      |- Signature -|
base64url({                          base64url({                          RSA-SHA256 of
  "alg": "RS256",                      "iss": "https://as.example",       header.payload
  "kid": "abc"                         "sub": "user123",                   using AS's
})                                     "aud": "photoprint",                private key
                                       "exp": 1713782400,
                                       "scope": "drive.readonly"
                                     })

RS verify bằng cách lấy public key của AS (qua JWKS endpoint), check chữ ký, rồi đọc claims. exp kiểm tra chưa hết hạn, aud kiểm tra token được cấp cho đúng RS này, sub cho biết user nào.

Nguyên tắc vàng: client tuyệt đối không parse nội dung access token. Kể cả khi biết nó là JWT, token được cấp cho RS, không phải cho client. Muốn biết thông tin user → dùng ID token hoặc gọi /userinfo.

Refresh token

Refresh Token: Credential dùng để đổi lấy access token mới khi access token hết hạn — mà không cần user đăng nhập lại. Thời hạn dài (ngày, tháng, thậm chí vô hạn). Chỉ dùng với endpoint /token của AS, không bao giờ gửi cho resource server.

Vì sao cần? Access token thời hạn ngắn (1 giờ) — nếu user phải login mỗi giờ thì quá khổ. Refresh token cho phép client tự động lấy access token mới trong nền.

text
  Client                    Auth Server
    |                           |
    | POST /token               |
    | grant_type=refresh_token  |
    | refresh_token=1//0gKJ...  |
    | client_id=...             |
    | client_secret=...  (if confidential)
    |-------------------------->|
    |                           |
    | new access_token          |
    | (optionally new refresh)  |
    |<--------------------------|

Refresh token quyền lực hơn access token — ai có refresh token có thể tạo access token vô thời hạn. Bảo mật cực kỳ chặt:

ID token (OpenID Connect)

OAuth 2.0 là framework authorization — trả lời câu hỏi "app này có quyền gì?". Nó không phải framework authentication — không trả lời "user này là ai?". Rất nhiều người triển khai OAuth cho "login" nhưng thực ra đang dùng sai tool.

OpenID Connect (OIDC) là layer mỏng trên OAuth 2.0 để thêm authentication. Thay đổi chính: khi scope bao gồm openid, AS trả thêm một ID token.

ID Token: JWT chứa thông tin danh tính của user (sub, email, name, ...). Được cấp cho client (không phải cho resource server), client verify nó để biết user là ai. ID token là của OIDC, không phải OAuth thuần.

Payload ví dụ:

json
{
  "iss": "https://accounts.google.com",
  "sub": "10769150350006150715113082367",
  "aud": "123456-abc.apps.googleusercontent.com",
  "exp": 1713782400,
  "iat": 1713778800,
  "email": "user@example.com",
  "email_verified": true,
  "name": "Nguyen Van A",
  "picture": "https://lh3.googleusercontent.com/..."
}

Ba điểm dễ sai với ID token:

  1. aud phải trùng client_id của client. Không check, client accept token được cấp cho app khác → attacker đăng nhập nick người khác vào app.
  2. ID token để client dùng, không để gửi lên backend như access token. Access token cho gọi API; ID token cho biết user là ai. Trộn lẫn là anti-pattern.
  3. ID token không thay thế session. Sau khi verify ID token lần đầu, backend nên tạo session riêng (cookie, JWT nội bộ) để quản lý đăng nhập. Không gửi ID token qua mỗi request.

So sánh ba token

Cách dễ nhất để nhớ ba token là nhớ mỗi token đi về một đích khác nhau:

text
  +-------------------------------------------------------------------+
  |                                                                   |
  |  +------------+                                                   |
  |  |            | --- access_token -----> [ Resource Server ]       |
  |  |            |     (Bearer header)                               |
  |  |            |                                                   |
  |  |   Client   | --- refresh_token ----> [ Authorization Server ]  |
  |  |            |     (POST /token)                                 |
  |  |            |                                                   |
  |  |            | --- id_token -------> (stays inside client,       |
  |  +------------+                        never sent anywhere)       |
  |                                                                   |
  +-------------------------------------------------------------------+

Ba đích, ba mục đích, ba token — không đổi chỗ.

Access TokenRefresh TokenID Token
Cho ai dùng?Resource ServerAuthorization ServerClient
Mục đíchGọi APILấy access token mớiBiết user là ai
Thời hạnNgắn (phút-giờ)Dài (ngày-tháng)Ngắn (phút-giờ)
FormatOpaque hoặc JWTOpaque (thường)JWT (luôn)
Client có parse không?KhôngKhôngCó — verify và đọc claims
Gửi đi đâu?Authorization: Bearer ... đến RSPOST /tokenKhông gửi đi đâu cả — chỉ client đọc
Thuộc spec nào?OAuth 2.0OAuth 2.0OpenID Connect

Token lifecycle thực tế

text
  t=0:      User logs in via 3LO
            Client gets: access_token (1h), refresh_token (30d), id_token (1h)
            Client verifies id_token, creates app session

  t=0..1h:  Client calls API with access_token

  t=1h:     access_token expires (401 from RS)
            Client silently calls /token with refresh_token
            Gets new access_token (1h), new refresh_token (30d, old one invalidated)

  t=1h..2h: Client calls API with new access_token

  ...continue until...

  t=30d:    Refresh token expires (if not used) OR user clicks "Logout"
            Client clears all tokens, deletes app session
            Next time: full 3LO flow again

Chi tiết nhỏ nhưng quan trọng: refresh token rotation. Nếu AS cấp refresh token mới mỗi lần refresh và invalidate cái cũ, một token rò rỉ chỉ còn hại đến lần refresh kế tiếp. Đây là "refresh token rotation with reuse detection" — best practice hiện đại.

Security considerations

OAuth được thiết kế cẩn thận, nhưng nó là framework — triển khai sai một chi tiết là phá vỡ cả chuỗi bảo mật. Section này điểm qua các tuyến tấn công quan trọng nhất và cách triển khai đúng.

state — chống CSRF và giữ context

Không dùng state là lỗi phổ biến nhất. Tấn công như sau:

text
  Victim browser              Attacker           Auth Server
      |                          |                   |
      |                          | 1. Start OAuth flow
      |                          |  with attacker's account
      |                          |------------------>|
      |                          |                   |
      |                          |<---code=attacker's|
      |                          |                   |
      |    2. Attacker crafts link:                  |
      |    https://victim-app/oauth/callback         |
      |       ?code=<attacker's code>                |
      |                                              |
      |<-- attacker tricks victim to click link ---- |
      |                                              |
      | 3. Victim's browser sends code to victim-app |
      |--------------------------------------------->|
      |                                              |
      | victim-app exchanges code, binds             |
      | attacker's account to victim's session       |
      | => Victim is now logged into ATTACKER'S acct |

Victim vào app, nghĩ rằng mình đang dùng tài khoản của mình — thực ra đang ngồi trong tài khoản attacker. Nếu victim upload dữ liệu nhạy cảm (ảnh, tài liệu), attacker đọc được ngay sau đó bằng cách đăng nhập lại bình thường.

Cách chống:

  1. Tạo state ngẫu nhiên (≥128 bits entropy) trước khi bắt đầu flow.
  2. Lưu ở phía client — cookie signed, sessionStorage, hoặc server-side session.
  3. Ở callback, so sánh state trả về với giá trị đã lưu. Không khớp → huỷ flow.

state cũng có công dụng thứ hai: giữ context của flow. Ví dụ, user đang ở trang /checkout thì bấm "Login with Google". Sau khi callback, app cần biết đưa user về /checkout. Có thể encode destination URL vào state (hoặc tốt hơn, lưu server-side, dùng một state ID random tham chiếu record đó).

redirect_uri — exact match, không đàm phán

AS kiểm tra redirect_uri trong authorize request khớp với danh sách đã đăng ký của client. Điểm chết người: so khớp chính xác, không phải "startsWith" hay "contains".

Các lỗi triển khai phổ biến và hệ quả:

text
  Registered: https://app.com/callback
  
  BAD  1: AS allows https://app.com/callback/anything
          -> attacker opens /callback/../evil -> redirect to evil
  
  BAD  2: AS allows *.app.com
          -> attacker finds XSS on static.app.com -> exfil code
  
  BAD  3: AS allows any subpath on app.com
          -> attacker finds open redirect at /search?url=...
             -> redirect_uri=https://app.com/search?url=evil.com
             -> AS redirects victim to evil.com with code in URL
  
  GOOD 1: Exact match only
          https://app.com/callback  ==  https://app.com/callback
  
  GOOD 2: For localhost (dev only) - allow variable port
          http://127.0.0.1:<any>/callback

RFC 9700 (OAuth Best Practice, 2025) nói rất rõ: AS phải so khớp redirect_uri kiểu exact-string, trừ trường hợp localhost đặc biệt. Nếu AS của bạn cho wildcard hay substring — đó là lỗi.

Từ phía client, đảm bảo:

Token storage — đau đầu nhất với frontend

Với backend confidential client, lưu token rất đơn giản: database có encryption at rest. Với SPA hoặc mobile, chọn lựa đều có đánh đổi:

text
  +----------------+-----------------------+-----------------+
  | Storage        | XSS resilience        | Verdict         |
  +----------------+-----------------------+-----------------+
  | localStorage   | NONE - any XSS reads  | AVOID           |
  |                | tokens in plain view  |                 |
  +----------------+-----------------------+-----------------+
  | sessionStorage | NONE - same           | AVOID for       |
  |                |                       | refresh tokens  |
  +----------------+-----------------------+-----------------+
  | Cookie         | Blocked from JS if    | Good IF         |
  | HttpOnly +     | HttpOnly; need CSRF   | combined with   |
  | Secure +       | defense (SameSite,    | SameSite=Lax    |
  | SameSite       | double-submit, etc.)  | or Strict       |
  +----------------+-----------------------+-----------------+
  | Memory-only    | XSS can still read    | OK for short-   |
  | (JS variable)  | while running, but    | lived access    |
  |                | gone on reload        | token in SPA    |
  +----------------+-----------------------+-----------------+
  | BFF pattern    | Tokens never touch    | BEST for SPA    |
  | (proxy backend)| browser at all        |                 |
  +----------------+-----------------------+-----------------+

BFF (Backend-For-Frontend) pattern: SPA không trực tiếp giữ token. Thay vào đó, có một backend nhỏ làm proxy — backend làm OAuth flow, giữ token, đặt session cookie (HttpOnly) cho browser. SPA gọi API qua backend, backend gắn access token vào request. Token không bao giờ vào JavaScript context → XSS không steal được token (mặc dù XSS vẫn có thể abuse session qua proxy calls). Đây là khuyến nghị hiện tại của OAuth 2.1 cho web app có UI SPA.

Với mobile và desktop native, dùng secure storage của OS:

Scope và least privilege

Xin scope tối thiểu đủ dùng. Không xin drive.full khi chỉ cần đọc một folder. Hai lý do:

  1. User tin tưởng hơn: consent screen gọn, user đồng ý dễ hơn.
  2. Thiệt hại khi token rò rỉ thấp hơn: kẻ tấn công có token nhưng không làm được gì ngoài scope đó.

Nếu app có tính năng phân vùng (một phần cần drive, một phần cần calendar), cân nhắc incremental authorization — chỉ xin scope khi user dùng tính năng đó, thay vì xin tất cả từ lúc login.

Token binding và mTLS — kỹ thuật nâng cao

Một access token bị lộ → attacker dùng được thoải mái cho đến hết hạn. Không có cách nào RS phân biệt request hợp lệ với request đánh cắp.

Sender-constrained tokens giải quyết vấn đề này: token chỉ dùng được nếu request đi kèm chứng cứ "đúng client". Hai cơ chế phổ biến:

Đây là tuyến phòng thủ cuối cùng — không phải ai cũng cần, nhưng với app xử lý tài chính hay dữ liệu nhạy cảm, DPoP là best practice 2025.

Checklist ngắn trước khi deploy

text
  [ ] PKCE (S256) trên tất cả client, kể cả có client_secret
  [ ] state random >=128 bits, verify ở callback
  [ ] redirect_uri exact match ở AS
  [ ] Callback endpoint không phải open redirect
  [ ] Refresh token rotation bật, reuse detection revoke session
  [ ] Access token TTL ngắn (15-60m)
  [ ] HTTPS-only, không HTTP trên production
  [ ] HttpOnly + Secure + SameSite cho cookie session
  [ ] Token storage đúng chỗ (BFF / Keychain / Keystore)
  [ ] Scope tối thiểu đủ dùng
  [ ] Log không chứa token, code, hay verifier
  [ ] Rate limit /token và /authorize

Thực chiến — Go backend với PKCE

Chúng ta sẽ triển khai một client OAuth 3LO đầy đủ với PKCE, state, và refresh token. Kịch bản: backend Go làm confidential client, xin scope openid email profile từ Google để đăng nhập user.

Code bên dưới dùng thư viện chuẩn golang.org/x/oauth2 + crypto/rand. Trong thực tế, bạn có thể dùng trực tiếp gói này — nó xử lý hầu hết boilerplate. Ở đây mình viết explicit để làm rõ từng bước.

Khởi tạo config

go
package main
 
import (
    "context"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "net/http"
    "time"
 
    "golang.org/x/oauth2"
)
 
var oauthConfig = &oauth2.Config{
    ClientID:     mustEnv("GOOGLE_CLIENT_ID"),
    ClientSecret: mustEnv("GOOGLE_CLIENT_SECRET"),
    RedirectURL:  "https://app.example.com/oauth/callback",
    Scopes:       []string{"openid", "email", "profile"},
    Endpoint: oauth2.Endpoint{
        AuthURL:  "https://accounts.google.com/o/oauth2/v2/auth",
        TokenURL: "https://oauth2.googleapis.com/token",
    },
}

Tạo code_verifier và code_challenge

go
func generatePKCE() (verifier, challenge string, err error) {
    // 32 random bytes -> base64url -> 43-char string (spec-compliant length)
    raw := make([]byte, 32)
    if _, err = rand.Read(raw); err != nil {
        return "", "", err
    }
    verifier = base64.RawURLEncoding.EncodeToString(raw)
 
    sum := sha256.Sum256([]byte(verifier))
    challenge = base64.RawURLEncoding.EncodeToString(sum[:])
    return verifier, challenge, nil
}
 
func generateState() (string, error) {
    raw := make([]byte, 32)
    if _, err := rand.Read(raw); err != nil {
        return "", err
    }
    return base64.RawURLEncoding.EncodeToString(raw), nil
}

Ba điểm chú ý:

Handler: bắt đầu flow

go
func loginHandler(w http.ResponseWriter, r *http.Request) {
    verifier, challenge, err := generatePKCE()
    if err != nil {
        http.Error(w, "internal error", 500)
        return
    }
    state, err := generateState()
    if err != nil {
        http.Error(w, "internal error", 500)
        return
    }
 
    // Lưu state + verifier vào server-side session (redis, cookie signed, v.v.)
    // Đây là demo — production cần session store thực.
    session := getOrCreateSession(w, r)
    session.Set("oauth_state", state)
    session.Set("pkce_verifier", verifier)
    session.Set("oauth_ts", time.Now().Unix())
    session.Save()
 
    authURL := oauthConfig.AuthCodeURL(
        state,
        oauth2.AccessTypeOffline,                                    // xin refresh token
        oauth2.SetAuthURLParam("code_challenge", challenge),
        oauth2.SetAuthURLParam("code_challenge_method", "S256"),
        oauth2.SetAuthURLParam("prompt", "consent"),                 // ép show consent
    )
 
    http.Redirect(w, r, authURL, http.StatusFound)
}

AccessTypeOffline là cách Google đánh dấu "xin refresh token". Không có nó, Google chỉ trả access token (không đủ để duy trì session dài hạn).

Một lưu ý nhỏ về prompt=consent: tham số này bắt buộc Google hiện consent screen mỗi lần login, kể cả khi user đã đồng ý trước đó. Trong demo đây hữu ích để đảm bảo Google luôn cấp refresh token mới. Nhưng với production UX, thường chỉ cần dùng prompt=consentlần đầu user cấp quyền (để lấy refresh token) — các lần đăng nhập sau bỏ tham số này để UX mượt hơn. Một cách phổ biến: nếu database đã có refresh token của user → omit prompt; nếu chưa có → thêm prompt=consent.

Handler: callback

go
func callbackHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
 
    // 1. Check error trả về từ AS
    if errParam := r.URL.Query().Get("error"); errParam != "" {
        http.Error(w, "oauth error: "+errParam, 400)
        return
    }
 
    // 2. Verify state
    session := getSession(r)
    expectedState := session.GetString("oauth_state")
    if expectedState == "" || expectedState != r.URL.Query().Get("state") {
        http.Error(w, "invalid state", 400)
        return
    }
 
    // 3. Lấy verifier từ session
    verifier := session.GetString("pkce_verifier")
    if verifier == "" {
        http.Error(w, "missing pkce verifier", 400)
        return
    }
 
    // 4. Cleanup ngay — state và verifier chỉ dùng một lần
    session.Delete("oauth_state")
    session.Delete("pkce_verifier")
    session.Save()
 
    // 5. Đổi code lấy token
    code := r.URL.Query().Get("code")
    token, err := oauthConfig.Exchange(
        ctx, code,
        oauth2.SetAuthURLParam("code_verifier", verifier),
    )
    if err != nil {
        http.Error(w, "token exchange failed: "+err.Error(), 500)
        return
    }
 
    // 6. Xử lý ID token (OIDC)
    rawIDToken, _ := token.Extra("id_token").(string)
    claims, err := verifyIDToken(ctx, rawIDToken)
    if err != nil {
        http.Error(w, "invalid id_token: "+err.Error(), 401)
        return
    }
 
    // 7. Tạo app session gắn với user
    userSession := createUserSession(w, r, claims.Subject, claims.Email)
    userSession.StoreRefreshToken(token.RefreshToken) // encrypt at rest
    userSession.Save()
 
    http.Redirect(w, r, "/dashboard", http.StatusFound)
}

Bảy bước trên là mẫu chuẩn. Đừng thiếu bước nào.

Verify ID token (OIDC)

go
import "github.com/coreos/go-oidc/v3/oidc"
 
var oidcProvider *oidc.Provider
var idTokenVerifier *oidc.IDTokenVerifier
 
func init() {
    ctx := context.Background()
    var err error
    oidcProvider, err = oidc.NewProvider(ctx, "https://accounts.google.com")
    if err != nil {
        panic(err)
    }
    idTokenVerifier = oidcProvider.Verifier(&oidc.Config{
        ClientID: oauthConfig.ClientID,
    })
}
 
type idClaims struct {
    Subject       string `json:"sub"`
    Email         string `json:"email"`
    EmailVerified bool   `json:"email_verified"`
    Name          string `json:"name"`
}
 
func verifyIDToken(ctx context.Context, raw string) (*idClaims, error) {
    tok, err := idTokenVerifier.Verify(ctx, raw)
    if err != nil {
        return nil, err
    }
    var c idClaims
    if err := tok.Claims(&c); err != nil {
        return nil, err
    }
    if !c.EmailVerified {
        return nil, fmt.Errorf("email not verified")
    }
    return &c, nil
}

oidc.NewProvider đọc metadata từ /.well-known/openid-configuration để biết JWKS URL, issuer, các endpoint. Verifier tự cache key và rotate. Verify kiểm tra đủ thứ: signature, iss, aud, exp, iat, nonce (nếu có).

Refresh token khi cần

go
func callGoogleAPI(ctx context.Context, s *UserSession, url string) ([]byte, error) {
    tokenSource := oauthConfig.TokenSource(ctx, &oauth2.Token{
        RefreshToken: s.RefreshToken(),
    })
    // TokenSource tự động refresh khi access token hết hạn
    // và trả về access token mới.
 
    client := oauth2.NewClient(ctx, tokenSource)
    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
 
    // Nếu token source trả refresh token mới, lưu lại
    newTok, err := tokenSource.Token()
    if err == nil && newTok.RefreshToken != s.RefreshToken() {
        s.StoreRefreshToken(newTok.RefreshToken) // rotation
        s.Save()
    }
 
    return readAll(resp.Body)
}

oauth2.TokenSource xử lý refresh ngầm. Bạn chỉ cần theo dõi xem refresh token có rotate không để lưu giá trị mới.

SPA variant — BFF thay vì expose token

Nếu frontend là SPA (React/Vue/Svelte), khuyến nghị không đưa token xuống browser. Luồng:

text
  SPA (browser)         BFF (backend)         Google AS           Google API
      |                       |                   |                   |
      | GET /auth/login       |                   |                   |
      |---------------------->|                   |                   |
      |                       | build auth URL,   |                   |
      |                       | save state/verif  |                   |
      |                       | in server session |                   |
      |   302 redirect to AS  |                   |                   |
      |<----------------------|                   |                   |
      |-------------------------->(standard OAuth flow at AS)         |
      |<--------------------------302 back to BFF with code + state   |
      |                       |                   |                   |
      |                       | exchange code     |                   |
      |                       |------------------>|                   |
      |                       |<------------------| tokens            |
      |                       | store tokens      |                   |
      |                       | server-side       |                   |
      |   Set-Cookie session  |                   |                   |
      |<----------------------|                   |                   |
      |                       |                   |                   |
      | GET /api/drive/files  |                   |                   |
      | (with session cookie) |                   |                   |
      |---------------------->|                   |                   |
      |                       | inject Bearer     |                   |
      |                       | from stored token |                   |
      |                       |------------------------------------>|
      |                       |<------------------------------------|
      |    response data      |                                      |
      |<----------------------|                                      |

SPA code chỉ gọi API "của mình" (BFF), không bao giờ gọi Google trực tiếp. Session cookie đi kèm request vì browser tự làm (HttpOnly, SameSite=Lax). Token ở nguyên trong BFF. XSS trên SPA không đọc được token.

Đây là kiến trúc mình khuyến nghị cho 95% app web có frontend SPA. Chỉ native mobile app và một số CLI thực sự cần giữ token local.

Những cạm bẫy thường gặp

Ba section trước đã vẽ "đường đúng". Section này là "bảo tàng sai lầm" — những lỗi mình đã thấy trong code review, pentest report, hoặc incident post-mortem. Nếu đang triển khai 3LO, đọc list này như checklist ngược: bạn đang làm bất kỳ cái nào?

1. Dùng Implicit Flow

Triệu chứng: response_type=token trong authorize URL, access token trả thẳng qua URL fragment.

Tại sao sai: access token nằm trong location.hash — bị log ở browser history, có thể leak qua document.referrer nếu redirect lại. Không có token exchange, nên không có cách cấp refresh token an toàn. PKCE không áp dụng được.

Sửa: đổi sang Authorization Code Flow + PKCE. OAuth 2.1 chính thức gỡ bỏ Implicit Flow.

2. Thiếu state (hoặc state dễ đoán)

Triệu chứng: không có tham số state, hoặc state=userId123 (đoán được).

Tại sao sai: CSRF attack — attacker inject authorization code của mình vào session của victim. Section Security đã diễn giải kỹ.

Sửa: state phải random ≥128 bit, tạo mới mỗi flow, lưu server-side (hoặc cookie có signature), verify exact match ở callback.

3. redirect_uri quá lỏng

Triệu chứng: AS đăng ký https://app.com/*, hoặc client tự xây redirect_uri từ query param.

Tại sao sai: attacker dùng open redirect trên domain bạn, hoặc XSS trên subdomain, để exfiltrate authorization code.

Sửa: AS whitelist exact match. Client hardcode redirect_uri, không bao giờ lấy từ input.

4. Lưu token trong localStorage

Triệu chứng: localStorage.setItem('access_token', token).

Tại sao sai: mọi XSS trong app (hoặc trong dependency) đều đọc được localStorage. Một lần npm install package lừa đảo là mất sạch token của mọi user đang online.

Sửa: với SPA, dùng BFF pattern, token ở backend, SPA dùng session cookie HttpOnly. Nếu phải giữ token ở SPA, dùng memory-only (biến JS, mất khi reload) và short-lived access token không refresh token.

5. Không kiểm tra aud của ID token

Triệu chứng: decode ID token, đọc email, log user vào — không check aud.

Tại sao sai: attacker có ID token do Google cấp cho app khác (có thể là app của chính attacker). Gửi token đó đến app bạn, nếu bạn tin "Google đã ký = hợp lệ" và login user đó, bạn đang cho attacker đăng nhập vào account trùng email trong app bạn.

Sửa: aud phải trùng client_id của bạn. Dùng thư viện OIDC verify đúng chuẩn, đừng tự decode JWT.

6. Gửi access token ra ngoài RS được cấp

Triệu chứng: frontend gọi analytics, Sentry, hoặc third-party API có gắn Authorization: Bearer ... do nhầm lẫn.

Tại sao sai: third-party giờ có token, có thể gọi RS của bạn. Token cho Google Drive thì không dùng gọi Sentry được, nhưng log của Sentry sẽ chứa token plaintext — leak.

Sửa: separate HTTP client cho các target khác nhau. Chỉ client "gọi RS" mới gắn token.

7. Không rotate refresh token, hoặc rotate mà không revoke cái cũ

Triệu chứng: AS trả refresh token như nhau mọi lần; hoặc trả token mới nhưng cái cũ vẫn dùng được.

Tại sao sai: nếu refresh token rò rỉ, attacker giữ quyền truy cập vô thời hạn (thời hạn refresh token có thể là tháng/năm). Không có cơ chế phát hiện.

Sửa: refresh token rotation — AS phát token mới, invalidate token cũ. Nếu thấy token cũ được dùng lại sau khi đã rotate → revoke toàn bộ family (cả refresh chain và active access token) vì có dấu hiệu bị đánh cắp.

8. Access token sống quá lâu

Triệu chứng: access token 7 ngày, 30 ngày.

Tại sao sai: token rò rỉ qua log, qua browser extension, qua MitM, qua screenshot — attacker có cả tuần/tháng để khai thác. Revoke khó vì token thường là JWT stateless.

Sửa: access token 15-60 phút là đủ. Client dùng refresh token để lấy mới. Cân bằng giữa overhead (refresh nhiều) và nguy cơ (token leak).

Triệu chứng: scope name obscure, user không hiểu đang đồng ý gì.

Tại sao sai: đây là vấn đề UX nhưng cũng là vấn đề đạo đức. User "click fatigue" — bấm Allow theo quán tính, không hiểu trao quyền gì. Khi có sự cố, họ không biết truy trách nhiệm.

Sửa: với AS riêng, scope phải có description rõ ràng, hiển thị consent screen liệt kê từng quyền. Với AS bên thứ ba (Google, GitHub), chọn scope hẹp nhất đủ dùng.

10. Log chứa secret

Triệu chứng: server log có code=..., code_verifier=..., refresh_token=..., full Authorization: header.

Tại sao sai: log thường được forward đi nhiều nơi — SIEM, log aggregator, backup. Mỗi nơi là một bề mặt tấn công. Một engineer vô tình download log về máy, máy bị infect → lộ.

Sửa: redact ở middleware trước khi log. Whitelist fields được log, đen list query params nhạy cảm. Không bao giờ trust "tôi chỉ log header khi debug, prod không bật".

11. Không validate nonce với OIDC

Triệu chứng: dùng OIDC nhưng không gửi nonce, hoặc không verify.

Tại sao sai: nonce trong OIDC đóng vai trò giống state nhưng cho ID token — chống token replay. Không có nonce, ID token cũ có thể được reuse.

Sửa: tạo nonce random mỗi flow, include trong authorize URL, verify claim nonce trong ID token khớp.

12. Tin implicit grant khi AS của mình là custom

Triệu chứng: xây AS riêng, hỗ trợ implicit flow vì "dễ cho SPA".

Tại sao sai: thế giới đã tiến bộ — OAuth 2.1 loại implicit, mọi client hiện đại đều hỗ trợ Authorization Code + PKCE. Giữ implicit là cố tình tạo bề mặt tấn công.

Sửa: không hỗ trợ implicit ở AS mới. Nếu có legacy client đang dùng, migrate sớm — document ngưng hỗ trợ có timeline.

Tóm lại

Phần lớn các lỗi trên đến từ một trong ba nguyên nhân:

  1. Triển khai theo tutorial cũ (Implicit flow, localStorage token — phổ biến trong tutorial 2015-2018).
  2. Copy-paste không hiểu (state = constant, không PKCE).
  3. "Tối ưu" sai chỗ (access token dài hạn cho "đỡ refresh").

Cách phòng vệ tốt nhất: đọc RFC 9700 (Best Current Practice 2025), dùng thư viện được cộng đồng review (oauth2, go-oidc, oidc-client-ts), và đừng tự implement JWT verify trừ khi biết rõ mình làm gì.

Kết luận

3-Legged OAuth không phải "redirect sang Google rồi lấy token". Nó là một hệ thống được cân nhắc kỹ để trả lời một câu hỏi khó: làm thế nào để uỷ quyền truy cập mà không trao password — trong một môi trường mà mọi kênh đều có thể nghe lén hoặc bị chiếm quyền?

Những điểm đáng nhớ nhất:

Khi nào dùng 3LO, khi nào không

text
  Scenario                                 Recommended
  ---------------------------------------  -----------------
  Web app với backend + login user         3LO Auth Code + PKCE
  SPA gọi API thay mặt user                3LO + BFF pattern
  Mobile app đọc dữ liệu user              3LO + PKCE + Keychain/Keystore
  CLI tool có user đăng nhập               3LO Device Code grant
  TV / IoT không có browser tốt            3LO Device Code grant

  Backend A gọi backend B (cùng tổ chức)   2LO Client Credentials
  Cron job đồng bộ dữ liệu nội bộ          2LO Client Credentials
  Microservice mesh bên trong              mTLS hoặc SPIFFE, không OAuth

Quy tắc thực dụng: có user cuối tham gia → 3LO; chỉ có server-to-server → 2LO; nội bộ trust zone → có thể không cần OAuth gì cả, mTLS đơn giản hơn.

Đi tiếp

Nếu bài này là điểm khởi đầu, dưới đây là những hướng mở rộng đáng đào sâu:

OAuth vừa đủ đơn giản để triển khai nhanh, vừa đủ phức tạp để triển khai sai. Phần lớn vụ rò rỉ "OAuth vulnerability" trong tin tức không phải lỗi của spec — mà là lỗi triển khai sai một chi tiết mà spec đã cảnh báo từ năm 2012. Đọc spec, hiểu các attack mà mỗi phần chống, và dùng thư viện đã được cộng đồng kiểm định. Đó là con đường an toàn nhất để đi qua "3 legs" này.

References

Ảnh sử dụng trong bài