Bảo mật Authentication trong SPA: LocalStorage hay HttpOnly Cookies?
JWT trong SPA nên lưu ở đâu? Phân tích LocalStorage, sessionStorage, memory, HttpOnly Cookies và BFF, kèm kiến trúc khuyến nghị, code Next.js và checklist production.
ShanverseUpdated 9 min read— viewsLoading views
Authentication
Web Security
Câu hỏi không chỉ là “JWT nằm ở đâu?”
JWT là một định dạng token, không phải cơ chế lưu trữ và cũng không tự làm authentication an toàn. Quyết định kiến trúc cần trả lời:
JavaScript trong trình duyệt có cần đọc token không?
Token dùng để gọi API cùng site hay API của bên thứ ba?
Ứng dụng có backend riêng hay chỉ là static SPA?
Access token và refresh token sống bao lâu?
Hệ thống cần logout, thu hồi, phát hiện replay và khóa thiết bị như thế nào?
Comments
Hai ứng dụng cùng dùng JWT có thể có mức an toàn hoàn toàn khác nhau. Một JWT sống 30 ngày trong localStorage nguy hiểm hơn access token sống 5 phút trong memory, được đổi bằng refresh token đã rotation. Một JWT nằm trong cookie cũng không an toàn nếu cookie thiếu Secure, SameSite, CSRF token hoặc bị dùng trên endpoint GET làm thay đổi trạng thái.
LocalStorage hoạt động thế nào?
localStorage là kho key-value theo origin và tồn tại sau khi đóng tab hoặc khởi động lại trình duyệt. SPA thường chọn nó vì code đơn giản:
Ưu điểm
Dễ triển khai và debug.
Token không được browser tự động gắn vào mọi request, nên cơ chế CSRF truyền thống khó khai thác hơn.
Phù hợp với API yêu cầu Authorization: Bearer và nhiều origin, nếu CORS được cấu hình đúng.
Không bị giới hạn kích thước cookie nhỏ.
Nhược điểm quyết định
Mọi JavaScript chạy trong cùng origin đều có thể đọc localStorage: code ứng dụng, dependency, tag analytics, browser extension trong một số mô hình đe dọa, và đặc biệt là payload XSS. Chỉ cần một lỗ hổng XSS:
Kẻ tấn công có thể mang bearer token sang thiết bị khác và sử dụng cho đến khi token hết hạn hoặc bị thu hồi. Dữ liệu vẫn tồn tại sau reload, khiến thời gian phơi nhiễm dài hơn. sessionStorage chỉ giảm độ bền vì nó gắn với tab; nó vẫn đọc được bởi XSS. Mã hóa token trước khi lưu cũng không giải quyết triệt để nếu khóa giải mã nằm trong chính JavaScript.
HttpOnly Cookie thay đổi mô hình đe dọa
Cookie có thuộc tính HttpOnly không thể đọc qua document.cookie. Server gửi cookie bằng response header:
HttpOnly: ngăn JavaScript đọc trực tiếp giá trị cookie.
Secure: chỉ gửi qua HTTPS.
SameSite=Lax hoặc Strict: hạn chế cookie trong request cross-site.
Prefix __Host-: yêu cầu Secure, Path=/ và không có Domain, giảm nguy cơ subdomain ghi đè cookie.
Không đặt Domain nếu chỉ host hiện tại cần cookie.
Ưu điểm
XSS không thể đơn giản đọc rồi exfiltrate token/session ID.
Browser quản lý việc gửi cookie, expiration và scope.
Phù hợp với BFF và session server-side.
Có thể dùng session ID opaque thay cho phơi JWT ra browser.
Nhược điểm
Cookie được browser tự động gắn vào request phù hợp. Vì vậy, nếu một website khác dụ browser gửi request thay đổi trạng thái, ứng dụng có thể gặp CSRF. Ngoài ra, HttpOnly không vô hiệu hóa XSS: script độc hại vẫn có thể gọi API cùng origin và hành động với tư cách người dùng trong lúc trang đang mở. Nó chỉ làm việc đánh cắp credential để tái sử dụng ở nơi khác khó hơn.
Cookie cũng chịu giới hạn kích thước và quy tắc domain/path. Nếu SPA và API ở hai site khác nhau, SameSite=None; Secure có thể cần thiết; khi đó SameSite không còn giúp chống CSRF và chính sách third-party cookie có thể ảnh hưởng thiết kế.
Bảng so sánh
Kiến trúc khuyến nghị: Backend-for-Frontend
Trong BFF, browser không nhận access token/refresh token OAuth. Luồng điển hình:
SPA điều hướng người dùng qua OIDC Authorization Code + PKCE.
BFF đổi authorization code lấy token ở server.
BFF lưu token trong session store an toàn hoặc kho mã hóa phía server.
Browser chỉ nhận một session cookie HttpOnly.
SPA gọi BFF; BFF gắn access token khi gọi resource server.
Refresh token rotation và thu hồi diễn ra ở backend.
Mermaid
Loading diagram…
Lợi ích lớn nhất là bearer token có giá trị cao không nằm trong môi trường JavaScript. BFF cũng có thể tập trung CSRF validation, token refresh, rate limit, audit và logout.
Ví dụ Next.js: cấp session cookie
Đừng trả JWT trong JSON rồi yêu cầu client tự lưu. Route Handler nên đặt cookie trực tiếp:
Ưu tiên session ID ngẫu nhiên, opaque. Nếu lưu JWT trực tiếp trong cookie, JWT vẫn là bearer credential: cần thời gian sống ngắn, kiểm tra chữ ký, iss, aud, exp, algorithm allowlist và quy trình thu hồi. Cookie không biến JWT thành token có khả năng chống replay.
Gọi API bằng cookie
Với same-origin, browser tự gửi cookie. Nếu gọi cross-origin và server cho phép credential:
API phải trả Access-Control-Allow-Credentials: true và một Access-Control-Allow-Origin cụ thể, không phải *. CORS không phải cơ chế chống CSRF tổng quát.
Phòng thủ CSRF khi dùng cookie
Áp dụng nhiều lớp:
SameSite=Lax hoặc Strict nếu flow cho phép; đặt rõ, không chỉ dựa vào mặc định.
Anti-CSRF token cho request thay đổi trạng thái.
Kiểm tra Origin, fallback sang Referer khi phù hợp.
Có thể kiểm tra Fetch Metadata như Sec-Fetch-Site.
Không thay đổi trạng thái qua GET.
Chỉ chấp nhận content type/header dự kiến và dùng CORS allowlist chặt.
Re-authentication cho đổi mật khẩu, email, MFA và giao dịch nhạy cảm.
Ví dụ client gửi CSRF token tách biệt:
Session cookie vẫn HttpOnly; token CSRF có thể được render vào trang hoặc cấp qua endpoint phù hợp và phải được server xác thực, không phải chỉ kiểm tra header có tồn tại.
Nếu SPA bắt buộc cầm access token
Một static SPA gọi API ngoài domain đôi khi không thể triển khai BFF ngay. Khi đó:
Dùng Authorization Code + PKCE, không dùng Implicit Flow.
Giữ access token trong memory nếu có thể; reload sẽ yêu cầu khôi phục session hoặc đăng nhập lại.
Dùng access token ngắn hạn, scope và audience tối thiểu.
Tránh refresh token lâu dài trong localStorage; nếu nhà cung cấp cấp refresh token cho SPA, phải có rotation, reuse detection và giới hạn lifetime.
Cân nhắc sender-constrained token như DPoP nếu hệ sinh thái hỗ trợ; đây không phải thuốc chữa XSS.
Không nhúng client secret trong bundle SPA; nó không thể là secret.
Memory không hoàn hảo: XSS đang chạy vẫn có thể lấy token hoặc gọi API. Nó chỉ giảm persistence và cửa sổ đánh cắp sau reload.
Session lifecycle và logout
Authentication an toàn cần nhiều hơn nơi lưu token:
Access token ngắn hạn; refresh/session có absolute timeout và idle timeout.
Rotate credential sau login, nâng quyền, đổi mật khẩu và sự kiện rủi ro.
Refresh token rotation với phát hiện reuse.
Logout xóa session server-side trước khi xóa cookie.
Có khả năng thu hồi theo người dùng, thiết bị hoặc session.
Cache-Control: no-store cho response chứa dữ liệu nhạy cảm.
Không ghi token vào URL, log, analytics, error report hoặc Redux devtools.
Theo dõi bất thường theo session, IP/ASN, thiết bị và token family mà không log credential thô.
XSS vẫn là ưu tiên số một
Dù dùng cookie HttpOnly, phải ngăn XSS bằng output encoding theo context, tránh unsafe sinks, sanitize HTML không tin cậy, CSP nghiêm ngặt, kiểm soát third-party scripts và cập nhật dependency. HttpOnly giảm hậu quả token theft, không biến một origin đã bị XSS thành an toàn.
Checklist chọn kiến trúc
Ưu tiên BFF giữ OAuth token ở server.
Browser chỉ nhận session ID opaque qua __Host-...; HttpOnly; Secure; SameSite=Lax/Strict.
Cookie-backed request thay đổi trạng thái có CSRF token và Origin validation.
Không lưu refresh token dài hạn trong localStorage.
Nếu token phải ở browser, ưu tiên memory và lifetime ngắn.
Dùng Authorization Code + PKCE cho SPA/OIDC.
JWT được validate đầy đủ: signature, algorithm, issuer, audience, expiry và token type.
Có rotation, revocation, logout server-side và reuse detection.
Có CSP, XSS prevention, dependency review và logging an toàn.
Kết luận
Nếu chỉ chọn giữa hai phương án, HttpOnly Cookie được harden đúng cách thường an toàn hơn localStorage cho authentication của ứng dụng web, vì nó ngăn JavaScript đọc và mang credential đi nơi khác. Nhưng đây không phải chiến thắng tuyệt đối: cookie làm CSRF trở thành yêu cầu bắt buộc và XSS vẫn có thể hành động trong phiên hiện tại.
Lựa chọn mạnh nhất thường không phải “nhét JWT vào cookie”, mà là BFF giữ token ở server + session cookie opaque cho browser + CSRF/XSS defense nhiều lớp. Nếu kiến trúc buộc SPA giữ token, ưu tiên memory, access token ngắn hạn và Authorization Code + PKCE thay vì persistence trong Web Storage.
Hướng dẫn kiến trúc 2FA cho Mobile Banking: SMS OTP, TOTP/Smart OTP, FIDO2, WebAuthn, passkeys, sinh trắc học cục bộ, đăng ký thiết bị, step-up và khôi phục tài khoản.
Hướng dẫn OAuth 2.0/OpenID Connect với Google và GitHub trong Next.js/NestJS: PKCE, state, nonce, callback, token storage, account linking, cookies và secret rotation.