jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Security for Frontend Devs · Part 5 — Auth Tokens & Secure Storage

Where to store session and auth tokens in the browser: httpOnly cookies vs localStorage vs in-memory, JWT pitfalls, OAuth PKCE + BFF for SPAs, Set-Cookie hardening, and strict TypeScript patterns — with exercises.

Phần 5/10 trong series Web Security for Frontend Devs. Trước: Tiếp:

Đây là Phần 5 của series 10 bài về những kiến thức bảo mật web mà mọi frontend dev nên biết — và chủ động phòng tránh. Phần 4 cho thấy cookie phiên httpOnly cần phòng CSRF vì trình duyệt tự gửi chúng. Phần này trả lời câu hỏi đôi: lưu trạng thái auth ở đâu trong trình duyệt, và tấn công nào trở thành rủi ro chính sau khi bạn chọn.


Câu hỏi trung tâm

Mọi SPA đã đăng nhập cuối cùng lưu thứ gì đó phía client: session id, JWT access token, refresh token, hay token OAuth. Lưu trữ sai không tạo lớp lỗ hổng mới — nó chọn lớp đã có nào thắng:

  • Cookie httpOnly → JS không đọc được token → XSS (Phần 2) không đánh cắp trực tiếp, nhưng CSRF (Phần 4) lợi dụng gửi tự động.
  • localStorage / sessionStoragemọi script trên origin bạn đọc được → một XSS đánh cắp token.
  • Chỉ trong bộ nhớ → bề mặt JS-readable nhỏ nhất, nhưng mất khi reload trừ khi đăng nhập lại.

Mô hình tư duy: Lưu trữ là công tắc threat model. Bạn không “bảo mật hơn” nói chung — bạn đổi rủi ro XSS lấy rủi ro CSRF (và ngược lại).


So sánh ba lựa chọn lưu trữ

httpOnly cookie Secure · HttpOnly · SameSite JavaScript cannot read it → XSS cannot steal the token needs CSRF defense localStorage token = "eyJhbGci…" any XSS → localStorage.getItem → token exfiltrated prefer httpOnly cookies for session tokens
httpOnly cookies hide tokens from JS (XSS-resistant); localStorage is readable by any script on the page (XSS loses)

Server đặt session hoặc refresh token trong cookie trình duyệt tự đính kèm. Với HttpOnly, document.cookie và mẹo localStorage trong script inject không đọc được bí mật phiên. Đó là lý do đây là khuyến nghị mặc định cho web app cổ điểnrefresh token trong SPA hiện đại.

Đổi lại: trình duyệt sẽ gửi cookie đó trên request cross-site đủ điều kiện trừ khi SameSite và lớp CSRF chặn — đúng Phần 4.

localStorage / sessionStorage

Tutorial thích localStorage.setItem('token', jwt) vì dễ gắn Authorization: Bearer cho fetch. Về bảo mật đây là mặc định tệ nhất cho bí mật sống lâu: SOP không bảo vệ bạn khỏi XSS của chính mình. Mọi script — của bạn, dependency bị compromise, widget chat — chạy như bạn và gọi localStorage.getItem.

Phần 2 vẽ rõ: XSS → đánh cắp cookie hoặc token trong storage. Nếu token ở localStorage, XSS thắng ngay.

Trong bộ nhớ (closure module / React state)

Giữ access token chỉ trong biến JS khi tab mở. Sống qua điều hướng SPA; chết khi refresh cứng trừ khi refresh im lặng qua cookie httpOnly hoặc redirect đăng nhập. Kẻ tấn công cần XSS đang sống trên phiên mở — vẫn thảm họa, nhưng không có "eyJhbGci…" bền trên đĩa cho mọi script quét.


Kết luận: dùng gì năm 2025

PatternSession / refreshAccess token (short-lived)Primary risk
Traditional server-rendered apphttpOnly session cookie(server-side session)CSRF → Part 4
SPA + separate APIhttpOnly refresh cookiein-memory bearerXSS on access window; CSRF on refresh endpoint
SPA storing JWT in localStorage❌ avoid❌ avoidXSS → full account takeover

Khuyến nghị: Ưu tiên cookie httpOnly + Secure + SameSite cho session và refresh. Nếu API cần Authorization: Bearer, giữ access token trong bộ nhớ, phát hạn ngắn (phút), refresh qua cookie httpOnly ở endpoint riêng. Giảm thời gian sống mọi nơi; không bao giờ log token.

Auth dựa cookie cho HTML cùng site: frontend thường không đụng tokenfetch('/api/profile', { credentials: 'include' }) và trình duyệt lo phần còn lại.


AttributeRole
HttpOnlyJS cannot read → blocks XSS exfiltration of this cookie
SecureSent only on HTTPS
SameSite=Lax (or Strict)Limits cross-site cookie sends → CSRF mitigation Part 4
Domain / PathScope; narrower is safer
Max-Age / ExpiresSession lifetime; shorter is better
__Host- prefixRequires Secure, no Domain, Path=/ — strongest cookie binding

Ví dụ header kiểu production:

Set-Cookie: __Host-session=7f3c9a2e1b4d8f0a6c5e3b2a1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600

Tiền tố __Host- bảo trình duyệt tuân thủ từ chối biến thể scope sai (không rò subdomain, chỉ HTTPS).


Cạm bẫy JWT (góc frontend)

JWT được (toàn vẹn), không mã hóa (bí mật). Ai có chuỗi đều decode base64 payload và đọc claim.

Đừng đặt secret hay PII trong payload — coi như bưu thiếp. User id và role thì thường; mật khẩu, API key, số định danh thì không.

Thu hồi khó: đến exp, JWT bị đánh cắp vẫn dùng được trừ khi server giữ denylist hoặc TTL rất ngắn + refresh. Frontend kiểm exp bằng jwt-decode ổn cho UX; quyết định bảo mật thuộc server.

Vấn đề phía server cần nhận ra khi review: tấn công alg: none, không verify chữ ký, nhận token sai issuer/audience. Việc của bạn là không khuếch đại bằng cách cache JWT sống lâu trong localStorage.


OAuth / OIDC cho SPA

Hướng dẫn hiện đại từ thực hành IETF/OAuth:

  • Dùng Authorization Code + PKCE, không implicit flow (token trong fragment URL) đã deprecated.
  • Ưu tiên backend-for-frontend (BFF) trên cùng origin: SPA gọi /api/auth/* cùng site; BFF giữ client secret, set cookie httpOnly, nói chuyện IdP.
  • Tránh đưa access token cho analytics bên thứ ba, error reporter, tag manager — script đó chạy trong ngữ cảnh trang bạn.

Vệ sinh thực tế

  • Không log token, header Authorization, hay dòng Set-Cookie đầy đủ ở client hay pipeline log dùng chung.
  • Xóa khi logout: hết hạn cookie phía server (Max-Age=0), xóa holder trong bộ nhớ, reset state client.
  • Xoay refresh token mỗi lần dùng để phát hiện đánh cắp.
  • Script bên thứ ba (quảng cáo, A/B, chat) cùng quyền origin với code auth — coi như code không tin cậy gần secret.
  • Phòng thủ nhiều lớp vẫn áp dụng: sửa XSS (Phần 2), CSP (Phần 3), CSRF cho cookie (Phần 4).

Access token trong bộ nhớ + wrapper fetch (type chặt, chỉ placeholder):

type AccessToken = { readonly value: string };

function parseAccessToken(raw: string): AccessToken | null {
  const trimmed = raw.trim();
  return trimmed.length > 0 ? { value: trimmed } : null;
}

const tokenHolder = (() => {
  let access: AccessToken | null = null;
  return {
    set(raw: string) {
      access = parseAccessToken(raw);
    },
    get(): AccessToken | null {
      return access;
    },
    clear() {
      access = null;
    },
  };
})();

export async function apiFetch(
  input: string,
  init: RequestInit = {},
): Promise<Response> {
  const token = tokenHolder.get();
  const headers = new Headers(init.headers);
  if (token) {
    headers.set('Authorization', `Bearer ${token.value}`);
  }
  return fetch(input, { ...init, headers, credentials: 'omit' });
}

// After login, server returns short-lived access in JSON body (not localStorage):
// tokenHolder.set("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c");

Phiên cookie — không token trong JS; token CSRF + credentials: 'include':

function readCsrfFromMeta(): string | null {
  const el = document.querySelector('meta[name="csrf-token"]');
  const content = el?.getAttribute('content');
  return typeof content === 'string' && content.length > 0 ? content : null;
}

export async function sessionFetch(
  input: string,
  init: RequestInit = {},
): Promise<Response> {
  const csrf = readCsrfFromMeta();
  const headers = new Headers(init.headers);
  if (csrf) {
    headers.set('X-CSRF-Token', csrf);
  }
  return fetch(input, {
    ...init,
    headers,
    credentials: 'include',
  });
}

Tương phản: mẫu bearer tối ưu app kiểu API và dồn rủi ro XSS trong cửa sổ access token; mẫu cookie tối ưu auth môi trường và dồn rủi ro CSRF trừ khi có kiểm soát Phần 4.


Checklist frontend

Trước khi ship auth:

  • Session / refresh trong cookie httpOnly + Secure + SameSite, không localStorage.
  • Access token sống ngắn; refresh qua cookie httpOnly hoặc BFF.
  • Phiên cookie: token CSRF trên mutation (Phần 4).
  • Bearer trong JS: giả định mọi XSS là thua — ưu tiên Phần 2 + CSP.
  • OAuth SPA: PKCE + code flow; token qua BFF khi có thể.

Bài tập / Exercises

1. Team lưu JWT 7 ngày trong localStorage vì “SPA stateless.” Nêu một tấn công từ Phần 2 chiếm tài khoản ngay, và thay đổi lưu trữ bỏ đánh cắp bền.

Lời giải

XSS stored hoặc DOM chạy như user, gọi localStorage.getItem('token'), rồi exfil JWT. Chuyển refresh sống lâu sang cookie httpOnly (access ngắn trong bộ nhớ hoặc session server) để script inject không đọc được credential.

2. Bạn chuyển phiên sang HttpOnly; Secure; SameSite=Lax. Phòng thủ nào từ Phần 4 vẫn cần trên POST /api/transfer, và vì sao chỉ HttpOnly không chặn CSRF?

Lời giải

Vẫn cần token chống CSRF (hoặc kiểm Origin tương đương + hiểu SameSite) trên route đổi trạng thái. HttpOnly chỉ chặn đọc cookie từ JS; trình duyệt vẫn gửi trên POST cross-site trừ khi SameSite chặn — Lax không phủ mọi trường hợp Phần 4 ghi.

3. Phác luồng auth SPA: access trong bộ nhớ, refresh trong cookie httpOnly, refresh im lặng khi 401 — component nào set token nào, ở đâu?

Lời giải

Đăng nhập: BFF/Auth server trả JSON { accessToken } (→ tokenHolder.set) Set-Cookie refresh httpOnly. apiFetch gắn Bearer từ bộ nhớ. Khi 401, gọi POST /api/auth/refresh với credentials: 'include' (chỉ cookie), server xoay refresh + trả access JSON mới. Logout: server xóa cookie + client tokenHolder.clear().

Nâng cao:So sánh lưu OAuth access token ở (A) localStorage, (B) trong bộ nhớ, (C) cookie httpOnly trên origin SPA qua BFF. Với mỗi cách, ai đọc được khi XSS, ai kích hoạt dùng khi CSRF?

Lời giải

Mọi XSS đọc và exfil; CSRF không liên quan Bearer trừ khi kẻ tấn công cũng chạy JS. XSS đang sống trên tab mở đọc bộ nhớ; không bản sao bền trên đĩa. XSS không đọc cookie httpOnly; CSRF có thể khiến trình duyệt gửi cookie trên request có credential — giảm bằng SameSite + CSRF trên mutation BFF. Mẫu BFF giữ secret khỏi bundle SPA và client secret IdP khỏi trình duyệt.


Điểm chính

  • Lưu trữ chọn kẻ thù frontend chính: cookie httpOnly → tập trung CSRF; bearer localStorage → tập trung XSS.
  • Ưu tiên httpOnly + Secure + SameSite cho session/refresh; access token sống ngắn và tốt nhất trong bộ nhớ.
  • JWT đọc được — không secret trong payload; TTL ngắn + refresh; server validate, exp client chỉ là UX.
  • OAuth SPA: Authorization Code + PKCE; BFF xử lý token; không implicit flow.
  • Xếp Phần 2 XSS, Phần 3 CSP, Phần 4 CSRF — chỉ lưu trữ không bao giờ đủ.

Tiếp theo

Phần 6 — CORS Explained: khi nào trình duyệt cho JS đọc phản hồi cross-origin, Access-Control-Allow-* hoạt động thế nào, và vì sao CORS không thay auth.