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.

Part 5 of 10 in the Web Security for Frontend Devs series {Phần 5/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 4 — CSRF & SameSite Cookies · Next {Tiếp}: Part 6 — CORS Explained.

This is Part 5 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đâ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}. Part 4 showed that httpOnly session cookies need CSRF defenses because the browser sends them automatically {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}. This part answers the paired question: where should you store auth state in the browser, and which attack becomes your main risk once you choose {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}.


The central question {Câu hỏi trung tâm}

Every authenticated SPA eventually stores something client-side: a session id, a JWT access token, a refresh token, or OAuth tokens {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}. The wrong storage choice does not create a new vulnerability class — it picks which existing class wins {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}:

  • httpOnly cookie → JavaScript cannot read the token → XSS (Part 2) cannot exfiltrate it directly, but CSRF (Part 4) can abuse automatic sends {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 / sessionStorageany script on your origin can read it → one XSS steals the token {localStorage / sessionStoragemọi script trên origin bạn đọc được → một XSS đánh cắp token}.
  • In-memory only → smallest JS-readable surface, but gone on full reload unless you re-authenticate {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}.

Mental model {Mô hình tư duy}: storage is a threat-model switch. You are not “more secure” in the abstract — you are trading XSS exposure for CSRF exposure (and vice versa) {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)}.


Three storage options compared {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)

The server sets a session or refresh token in a cookie the browser attaches automatically {Server đặt session hoặc refresh token trong cookie trình duyệt tự đính kèm}. With HttpOnly, document.cookie and localStorage tricks in injected script cannot read the session secret {Với HttpOnly, document.cookie và mẹo localStorage trong script inject không đọc được bí mật phiên}. That is why this is the default recommendation for classic web apps and for refresh tokens in modern SPAs {Đó là lý do đây là khuyến nghị mặc định cho web app cổ điểnrefresh token trong SPA hiện đại}.

Trade-off: the browser will send that cookie on qualifying cross-site requests unless SameSite and CSRF layers stop it — exactly Part 4 {Đổ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 {localStorage / sessionStorage}

Tutorials love localStorage.setItem('token', jwt) because it is easy for fetch with Authorization: Bearer {Tutorial thích localStorage.setItem('token', jwt) vì dễ gắn Authorization: Bearer cho fetch}. Security-wise it is the worst default for long-lived secrets: same-origin policy does not protect you from your own XSS {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}. Any script — yours, a compromised dependency, a chat widget — runs as you and calls localStorage.getItem {Mọi script — của bạn, dependency bị compromise, widget chat — chạy như bạn và gọi localStorage.getItem}.

Part 2 diagrams this explicitly: XSS → steal cookies or tokens in storage {Phần 2 vẽ rõ: XSS → đánh cắp cookie hoặc token trong storage}. If the token is in localStorage, XSS wins outright {Nếu token ở localStorage, XSS thắng ngay}.

In-memory (module closure / React state) {Trong bộ nhớ (closure module / React state)}

Keep the access token only in a JS variable while the tab is open {Giữ access token chỉ trong biến JS khi tab mở}. Survives SPA navigations; dies on hard refresh unless you silently refresh via an httpOnly cookie or redirect to login {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}. Attackers need live XSS on an open session — still catastrophic, but no persistent "eyJhbGci…" sitting on disk for every script to scrape {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}.


Verdict: what to use in 2025 {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

Recommendation {Khuyến nghị}: Prefer httpOnly + Secure + SameSite cookies for session and refresh tokens {Ưu tiên cookie httpOnly + Secure + SameSite cho session và refresh}. If the API expects Authorization: Bearer, keep the access token in memory, mint it short (minutes), and rotate refresh via httpOnly cookie on a dedicated endpoint {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}. Minimize lifetime everywhere; never log tokens {Giảm thời gian sống mọi nơi; không bao giờ log token}.

Cookie-based auth for same-site HTML: your frontend often does not touch the token at allfetch('/api/profile', { credentials: 'include' }) and the browser does the rest {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

Example production-style header {Ví dụ header kiểu production}:

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

The __Host- prefix tells compliant browsers to reject mis-scoped variants (no subdomain leakage, HTTPS only) {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)}.


JWT pitfalls (frontend lens) {Cạm bẫy JWT (góc frontend)}

JWTs are signed (integrity), not encrypted (confidentiality) {JWT được (toàn vẹn), không mã hóa (bí mật)}. Anyone with the string can base64-decode the payload and read claims {Ai có chuỗi đều decode base64 payload và đọc claim}.

Do not put secrets or PII in the payload — treat it like a postcard {Đừng đặt secret hay PII trong payload — coi như bưu thiếp}. User id and roles are common; passwords, API keys, and national IDs are not {User id và role thì thường; mật khẩu, API key, số định danh thì không}.

Revocation is hard {Thu hồi khó}: until exp, a stolen JWT works unless you maintain a server-side denylist or very short TTL + refresh {đế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 checking exp in jwt-decode is fine for UX (proactive logout); security decisions belong on the server {Frontend kiểm exp bằng jwt-decode ổn cho UX; quyết định bảo mật thuộc server}.

Server-side issues you should recognize in reviews: alg: none attacks, failing to verify signatures, accepting tokens from the wrong issuer/audience {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}. Your job is to not amplify the problem by caching long-lived JWTs in localStorage {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 for SPAs {OAuth / OIDC cho SPA}

Modern guidance from IETF/OAuth working group practice {Hướng dẫn hiện đại từ thực hành IETF/OAuth}:

  • Use Authorization Code + PKCE, not the deprecated implicit flow (tokens in URL fragment) {Dùng Authorization Code + PKCE, không implicit flow (token trong fragment URL) đã deprecated}.
  • Prefer a backend-for-frontend (BFF) on your origin: the SPA talks to /api/auth/* on the same site; the BFF holds client secrets, sets httpOnly cookies, and talks to the IdP {Ư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}.
  • Avoid passing access tokens to third-party analytics, error reporters, or tag managers — those scripts run in your page context {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}.

Practical hygiene {Vệ sinh thực tế}

  • Never log tokens, Authorization headers, or full Set-Cookie lines in client or shared logging pipelines {Không log token, header Authorization, hay dòng Set-Cookie đầy đủ ở client hay pipeline log dùng chung}.
  • Clear on logout: expire cookies server-side (Max-Age=0), wipe in-memory holders, and reset client state {Xóa khi logout: hết hạn cookie phía server (Max-Age=0), xóa holder trong bộ nhớ, reset state client}.
  • Rotate refresh tokens on each use (refresh token rotation) to detect theft {Xoay refresh token mỗi lần dùng để phát hiện đánh cắp}.
  • Third-party scripts (ads, A/B, support chat) are in the same origin privilege as your auth code — treat them as untrusted code near secrets {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}.
  • Defense in depth still applies: fix XSS (Part 2), CSP (Part 3), CSRF for cookies (Part 4) {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)}.

In-memory access token + fetch wrapper (strict types, placeholders only) {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");

Cookie-based session — no token in JS; CSRF token + credentials: 'include' {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',
  });
}

Contrast {Tương phản}: bearer pattern optimizes for API-style apps and shifts exposure to XSS during the access-token window; cookie pattern optimizes for ambient auth and shifts exposure to CSRF unless Part 4 controls are in place {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}.


Frontend checklist {Checklist frontend}

Before you ship auth {Trước khi ship auth}:

  • Session / refresh in httpOnly + Secure + SameSite cookie, not localStorage {Session / refresh trong cookie httpOnly + Secure + SameSite, không localStorage}.
  • Access token short-lived; refresh via httpOnly cookie or BFF {Access token sống ngắn; refresh qua cookie httpOnly hoặc BFF}.
  • Cookie sessions: CSRF token on mutations (Part 4) {Phiên cookie: token CSRF trên mutation (Phần 4)}.
  • Bearer in JS: assume any XSS is game over — prioritize Part 2 + CSP {Bearer trong JS: giả định mọi XSS là thua — ưu tiên Phần 2 + CSP}.
  • OAuth SPA: PKCE + code flow; tokens via BFF when possible {OAuth SPA: PKCE + code flow; token qua BFF khi có thể}.

Bài tập / Exercises

1. Your team stores a 7-day JWT in localStorage “because SPAs are stateless.” Name the one attack from Part 2 that immediately owns the account, and the storage change that removes persistent exfiltration {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}.

Solution {Lời giải}

Stored or DOM XSS runs as the user and calls localStorage.getItem('token'), then exfiltrates the JWT {XSS stored hoặc DOM chạy như user, gọi localStorage.getItem('token'), rồi exfil JWT}. Move long-lived refresh to an httpOnly cookie (short access in memory or server session) so injected script cannot read the credential {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. You move the session to HttpOnly; Secure; SameSite=Lax. What defense from Part 4 do you still need on POST /api/transfer, and why doesn’t HttpOnly alone stop CSRF? {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?}

Solution {Lời giải}

You still need anti-CSRF tokens (or equivalent Origin checks + SameSite understanding) on state-changing routes {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 only stops reading the cookie from JS; the browser still sends it on cross-site POST unless SameSite blocks it — and Lax does not cover every case Part 4 documents {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. Sketch a SPA auth flow: access token in memory, refresh in httpOnly cookie, silent refresh on 401 — which component sets each token and where? {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?}

Solution {Lời giải}

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

Stretch {Nâng cao}: Compare storing OAuth access token in (A) localStorage, (B) in-memory, (C) httpOnly cookie on the SPA origin via BFF. For each, who can read it under XSS, and who can trigger its use under CSRF? {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?}

Solution {Lời giải}

(A) Any XSS reads and exfiltrates; CSRF irrelevant for Bearer unless attacker also runs JS {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}. (B) Live XSS on an open tab reads memory; no persistent disk copy {XSS đang sống trên tab mở đọc bộ nhớ; không bản sao bền trên đĩa}. (C) XSS cannot read httpOnly cookie; CSRF can cause the browser to send cookie on credentialed requests — mitigate with SameSite + CSRF on BFF mutations {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}. BFF pattern keeps secrets off the SPA bundle and IdP client secret off the browser {Mẫu BFF giữ secret khỏi bundle SPA và client secret IdP khỏi trình duyệt}.


Key takeaways {Điểm chính}

  • Storage picks your main frontend enemy: httpOnly cookies → CSRF focus; localStorage bearer → XSS focus {Lưu trữ chọn kẻ thù frontend chính: cookie httpOnly → tập trung CSRF; bearer localStorage → tập trung XSS}.
  • Prefer httpOnly + Secure + SameSite for session/refresh; keep access tokens short-lived and preferably in memory {Ưu tiên httpOnly + Secure + SameSite cho session/refresh; access token sống ngắn và tốt nhất trong bộ nhớ}.
  • JWTs are readable — no secrets in payload; short TTL + refresh; server validates, client exp is UX only {JWT đọc được — không secret trong payload; TTL ngắn + refresh; server validate, exp client chỉ là UX}.
  • OAuth SPAs: Authorization Code + PKCE; BFF for token handling; no implicit flow {OAuth SPA: Authorization Code + PKCE; BFF xử lý token; không implicit flow}.
  • Layer Part 2 XSS, Part 3 CSP, and Part 4 CSRF — storage alone is never enough {Xếp Phần 2 XSS, Phần 3 CSP, Phần 4 CSRF — chỉ lưu trữ không bao giờ đủ}.

Next up {Tiếp theo}

Part 6 — CORS Explained: when the browser lets JavaScript read cross-origin responses, how Access-Control-Allow-* works, and why CORS is not a substitute for auth {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}. Continue to Part 6 — CORS Explained.