Micro-frontend Learning
Giao tiếp và trạng thái

Authentication và session

Thiết kế auth owner, session contract và luồng login, refresh, logout cho micro-frontend; tách UI permissions khỏi backend authorization.

Giả sử bạn login ở header, mở cart thì cart lại yêu cầu login. Sau khi logout, badge đã biến mất nhưng request cart cũ vừa trả về lại đưa tên và giỏ hàng của người dùng trước lên màn hình. Cả hai lỗi đều có thể xuất hiện khi mỗi remote giữ một bản session và tự quyết định vòng đời của nó.

Trong micro-frontend, vấn đề không chỉ là “lưu token ở đâu”. Bạn cần một nơi điều phối session, một contract để các remote đọc trạng thái hiện tại, và một cơ chế ngăn công việc của session cũ cập nhật UI mới. Frontend phối hợp trải nghiệm; backend mới quyết định request có được phép thực hiện hay không.

Phạm vi và giả định

Bài dùng shop giả định gồm shell, catalog và cart. Endpoint, kiểu TypeScript và tên contract bên dưới là ví dụ thiết kế, không phải API sẵn có trong repository. Mặc định các remote chạy trong document của shell; iframe, nhiều tab và SSR có phần lưu ý riêng. Ví dụ không thay thế một SDK OIDC hoặc implementation session đã được review bảo mật.

Mục lục

Phân biệt identity session và permissions

Một user có thể đã login nhưng không được xem đơn hàng đang mở. Ngược lại, catalog có thể phục vụ người chưa login. Nếu gom cả hai tình huống thành isLoggedIn, bạn sẽ dễ đưa người thiếu quyền trở lại login dù việc login thêm lần nữa không giải quyết được gì.

Khái niệmCâu hỏi cần trả lờiNơi xác nhận cuối cùng
AuthenticationNgười dùng này là ai?Identity provider hoặc hệ thống xác thực của backend
SessionRequest hiện tại còn gắn với một phiên hợp lệ không?Backend session hoặc cơ chế kiểm tra credential của API
AuthorizationNgười này được làm hành động này trên tài nguyên này không?Backend xử lý request nghiệp vụ
UI permissionsNên hiện nút, menu hoặc màn hình nào?Frontend đọc projection quyền do backend cung cấp

Với OIDC, ID token cung cấp thông tin xác thực cho client; access token dùng để truy cập resource server theo contract của API. Đừng gửi ID token như access token chỉ vì cả hai có thể có dạng JWT. OAuth 2.0 tự nó là giao thức ủy quyền; OIDC bổ sung lớp identity để phục vụ login.

Session frontend là một projection, tức bản mô tả đủ cho UI: đang kiểm tra phiên, chưa login, đã login hay chưa xác nhận được. Nó không phải credential và không chứng minh một request sẽ được chấp nhận. Session có thể bị revoke hoặc quyền có thể đổi ngay sau khi UI đọc snapshot.

Ai sở hữu authentication

Mình chọn một auth module do platform team sở hữu, được shell khởi tạo cho runtime hiện tại. Module này không cần chứa màn hình login; nó chứa logic bootstrap, đọc session, điều phối login/logout và publish snapshot. UI login có thể là một remote riêng hoặc một trang của identity provider.

Thành phầnTrách nhiệmKhông nên làm
Identity providerXác thực identity, MFA, phát hành token theo cấu hìnhQuyết định toàn bộ nghiệp vụ cart chỉ từ việc user đã login
BFF hoặc backend authTạo, kiểm tra, hết hạn và revoke session của appTin userId hoặc roles do browser tự gửi lên
Auth moduleĐiều phối session trong runtime, quản lý lifecycle và credential nếu mô hình cầnCho mọi remote tự refresh hoặc sửa snapshot
ShellInject contract, điều phối route guard và trải nghiệm cấp appTự triển khai thêm một bản auth state song song
Catalog và cartĐọc snapshot, yêu cầu login khi cần, dọn state theo scopeTự parse cookie/token để dựng một session riêng
Backend domainEnforce authorization, ownership tài nguyên và tenantCoi nút đã bị ẩn ở UI là đủ bảo vệ endpoint

Ranh giới ở frontend có thể hình dung như sau:

direction: right
shell: Shell UI
cart: Cart UI
auth: Auth module
backend: Backend auth
shell -> auth: "Đọc snapshot / gửi command"
cart -> auth: "Đọc snapshot / gửi command"
auth -> backend: Session API
Shell UICart UIAuth moduleBackend auth Đọc snapshot / gửi command Đọc snapshot / gửi command Session API
Shell UICart UIAuth moduleBackend auth Đọc snapshot / gửi command Đọc snapshot / gửi command Session API
Ai sở hữu authentication

Đây là ownership, không phải sandbox. JavaScript chạy cùng document có thể gọi API và tác động DOM của nhau. Inject một reader thay vì cả store giảm coupling, nhưng không làm code remote không đáng tin trở nên an toàn.

Một package auth dùng chung cũng chưa bảo đảm một instance dùng chung. Nếu shell và cart đều gọi createAuth() khi import package, chúng vẫn có hai owner. Shell cần tạo instance rồi truyền đúng object contract vào mount context; cấu hình shared dependency không thay thế bước wiring này.

Chọn mô hình session

Không có yêu cầu rằng micro-frontend phải chia token cho mọi remote. Quyết định quan trọng là credential nằm ở browser hay backend, và browser gửi request nghiệp vụ qua đâu.

Với shop chạy dưới một origin ứng dụng và bạn kiểm soát backend, mình mặc định chọn BFF, tức Backend for Frontend. Browser giữ cookie session; BFF giữ OAuth access/refresh token phía server nếu cần gọi API downstream.

direction: right
browser: Browser UI
bff: BFF
api: API domain
idp: Identity provider
browser -> bff: Cookie session
bff -> api: Access token
bff -> idp: Token exchange / refresh
Browser UIBFFAPI domainIdentity provider Cookie session Access token Token exchange / refresh
Browser UIBFFAPI domainIdentity provider Cookie session Access token Token exchange / refresh
BFF và cookie session

Browser không đọc được cookie HttpOnly, nhưng có thể gọi /auth/session để lấy user và UI permissions tối thiểu. Cart gửi request tới endpoint của BFF; nó không cần biết token endpoint, token format hay refresh token.

Ví dụ cookie do backend set qua HTTPS:

Set-Cookie: __Host-shop-session=<opaque-session-id>; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store

__Host- yêu cầu Secure, Path=/ và không có Domain trên browser hỗ trợ prefix này. Opaque session ID là giá trị khó đoán do backend tạo, không phải user ID. Backend cần rotate session ID sau login hoặc thay đổi mức đặc quyền để chống session fixation, đồng thời enforce idle timeout và absolute timeout phía server.

SameSite=Lax ở đây là lựa chọn minh họa cho app có top-level redirect qua identity provider, không phải cấu hình dùng được cho mọi callback. Với callback dạng cross-site POST như form_post, transaction cookie và chính sách SameSite cần được kiểm tra riêng theo SDK/provider. Không đổi toàn bộ session cookie sang None chỉ để chữa một callback mà chưa xác định cookie nào thật sự cần gửi.

BFF giảm việc phơi bày OAuth token cho JavaScript, nhưng thêm một backend cần vận hành: session storage, CSRF protection, token lifecycle và routing tới API phải được thiết kế rõ. Nó cũng không loại bỏ XSS; code độc hại vẫn có thể gửi request bằng cookie hiện tại.

Browser gọi API bằng access token

Nếu sản phẩm chỉ có frontend static và API bắt buộc nhận bearer token trực tiếp, browser có thể là OAuth public client. Dùng Authorization Code với PKCE qua SDK được duy trì; không đặt client secret trong bundle và không chọn implicit flow cho thiết kế mới.

Khi đó auth module hoặc SDK chuyên trách giữ credential và điều phối refresh. Mình ưu tiên access token trong memory, tránh persist access/refresh token vào localStorage như lựa chọn mặc định vì JavaScript cùng origin đọc được chúng và dữ liệu sống qua reload. Memory giảm thời gian tồn tại, không ngăn code độc hại trong runtime đọc hoặc sử dụng token.

Nếu authorization server cấp refresh token cho public client, RFC 9700 yêu cầu sender-constrained refresh token hoặc refresh token rotation để phát hiện replay. Không tự làm rotation ở frontend; frontend tuân theo giao thức, còn authorization server phát hành, kiểm tra reuse và revoke theo chính sách của nó.

Tiêu chíBFF và cookieBearer token trong browser
OAuth token ở đâu?BackendAuth runtime trong browser
Remote cần đọc token?KhôngKhông bắt buộc; có thể dùng transport adapter
Rủi ro cần chú ýCSRF, session backend, XSS thực hiện requestToken bị lộ qua XSS, refresh concurrency, CORS
Phù hợp mặc địnhWeb app có backend cùng origin kiểm soát đượcStatic app không có BFF và API hỗ trợ public client

Với bearer model, inject transport theo API/domain như CartApi, thay vì một getAccessToken() mở cho mọi remote. Transport chỉ gắn token vào destination đã được allowlist và đúng audience; không nhận URL tùy ý rồi thêm Authorization vào mọi request. Nếu API có audience khác nhau, lấy token phù hợp cho từng resource server, không dùng một token toàn quyền để đơn giản hóa wiring.

Remote asset origin không phải auth origin

Catalog bundle có thể tải từ https://catalog-cdn.example, nhưng khi chạy trong document https://shop.example, code vẫn chạy trong context của shop. URL của JavaScript asset không tự tạo cookie jar hoặc security boundary cho catalog.

Đừng mở Domain=example.com để “mọi remote dùng chung cookie” chỉ vì asset nằm trên nhiều subdomain. Nếu chúng cùng document, chúng đã gọi được BFF của shell. Cookie domain rộng làm tăng phạm vi host nhận cookie và rủi ro từ subdomain không đáng tin.

Cũng cần phân biệt origin với site. https://shop.example.com và https://api.example.com khác origin nhưng thường cùng site; CORS và SameSite giải quyết hai câu hỏi khác nhau. Với cross-origin credentialed fetch, browser cần credentials: 'include', server cần origin cụ thể và Access-Control-Allow-Credentials: true; không dùng Access-Control-Allow-Origin: *. Các điều kiện đó vẫn không vượt qua được chính sách chặn third-party cookies.

Bootstrap session và trạng thái UI

Reload trang làm mất state trong memory, không nhất thiết làm mất session backend. Shell nên khởi tạo auth module rồi đọc session hiện tại. Remote tải sau phải đọc được snapshot này, không cần chờ một event login phát lại.

Một boolean ban đầu là false thường gây redirect nhầm: guard hiểu “chưa kiểm tra” thành “chưa login”. Contract nên biểu diễn ít nhất các trạng thái sau:

Trạng tháiÝ nghĩaHành vi UI mặc định
checkingChưa có kết luận cho session hiện tạiChờ, không redirect login và chưa tải dữ liệu riêng tư
anonymousBackend đã xác nhận không có session hợp lệCho xem public UI; route bảo vệ có thể yêu cầu login
authenticatedCó snapshot user đã được xác nhậnCho tải dữ liệu theo scope; vẫn xử lý API từ chối
signing-outĐã khóa scope cũ, đang kết thúc sessionDọn dữ liệu riêng tư, chặn request mới
unavailableKhông xác nhận được vì lỗi mạng/serverHiện retry hoặc lỗi kết nối, không kết luận user đã logout

Các tên này là contract minh họa, không phải yêu cầu dùng đúng một state machine cho mọi SDK. Nếu cho phép hiển thị snapshot cũ khi revalidate, cần thêm freshness rõ ràng và chính sách cho dữ liệu nhạy cảm; đừng gọi snapshot chưa xác nhận là session mới đã hợp lệ.

Với ví dụ trong bài, auth module chỉ công bố authenticated sau khi kiểm tra thành công. Remote public như catalog có thể render ngay, trong khi vùng riêng tư của cart chờ kết luận. Không cần chặn cả trang catalog chỉ vì session API chậm.

Session endpoint phải có chính sách response rõ: chẳng hạn 200 với projection tối thiểu, 401 nếu không có session, và lỗi mạng/5xx là chưa xác nhận được. Không biến mọi exception thành anonymous. Response chứa session/user riêng tư dùng Cache-Control: no-store và không được cache công khai ở CDN.

Luồng login và quay lại điểm đến

Login không hoàn tất chỉ vì browser quay về callback URL. Bạn cần kiểm tra transaction, hoàn tất session của app và công bố trạng thái mới trước khi mở lại màn hình bảo vệ.

Một nơi xử lý callback

Với BFF dùng OIDC, luồng ở mức thiết kế là:

  1. Cart yêu cầu login với destination nội bộ, ví dụ /cart?step=delivery.
  2. Auth owner phối hợp router để bắt đầu login qua backend. Backend lưu transaction gắn với browser và destination đã validate.
  3. Backend redirect sang identity provider với redirect URI đã đăng ký, PKCE và các tham số chống giả mạo/correlate transaction theo SDK.
  4. Identity provider xác thực rồi redirect về callback duy nhất do auth backend xử lý.
  5. Backend kiểm tra transaction, state, PKCE và các điều kiện OIDC như issuer, audience, signature, expiry, nonce khi áp dụng. Backend đổi code lấy token và tạo session của app.
  6. Browser trở lại app; auth module đọc session mới. Chỉ sau khi nhận authenticated, shell mở destination còn hợp lệ.

state, PKCE và OIDC nonce có vai trò liên quan nhưng không nên bị gom thành một “chuỗi ngẫu nhiên” tự viết. Dùng SDK/protocol implementation để tạo, lưu, validate và tiêu thụ transaction một lần. Với browser public client, bước đổi code do SDK browser thực hiện; không vì thế mà mỗi remote xử lý cùng callback.

Callback route nên thuộc auth owner, nằm ngoài guard bắt buộc đã login, và tránh tải remote/analytics không cần thiết trước khi xử lý callback. Không đưa access token hoặc refresh token vào URL. Authorization code cũng là dữ liệu nhạy cảm: xử lý sớm, tránh log và dọn query callback khỏi URL sau khi hoàn tất theo SDK/router.

Nếu người dùng hủy login hoặc callback lỗi, hiển thị trạng thái có thể thử lại. Không tự mở lại login liên tục. Nếu login xong nhưng API domain báo thiếu quyền, chuyển tới forbidden, không gửi người dùng qua identity provider thêm vòng nữa.

Return URL là input không tin cậy

returnTo có thể bị sửa qua query hoặc message. Đừng redirect trực tiếp tới giá trị đó và đừng chỉ kiểm tra startsWith('/'): //evil.example có thể được parse thành URL của host khác.

Mặc định mình dùng destination có cấu trúc từ routing contract, để shell xây URL từ danh sách route được hỗ trợ. Nếu phải nhận URL string, cần parse bằng trusted origin, kiểm tra cùng origin, basename của app và route allowlist; từ chối URL có credentials hoặc scheme ngoài chính sách. Backend phải validate lại trước khi redirect, không tin kết quả validation từ browser.

Giữ query và fragment cần cho trải nghiệm, nhưng không giữ password, token hoặc dữ liệu cá nhân trong return URL. Với BFF, có thể lưu destination trong login transaction và chỉ dùng state opaque để tham chiếu; không cần đưa toàn bộ destination vào tham số gửi sang provider.

Sau login, shell thường replace entry login thay vì push thêm một entry login còn dùng được. Đồng thời đánh giá lại quyền cho destination: user vừa login có thể không được vào màn hình ban đầu. Hành vi history chi tiết nằm ở bài routing, không nên để mỗi remote tự redirect bằng một quy tắc khác.

Session contract không chứa credential

Một remote thường cần biết “đã login chưa”, “scope dữ liệu nào đang active” và “yêu cầu login bằng cách nào”. Nó không cần biết session ID, raw JWT hoặc cấu trúc nội bộ của auth store.

Snapshot subscription và commands

Contract dưới đây dùng epoch là số phiên bản cục bộ của runtime, không phải session ID, credential hay revision của identity provider:

export type SessionUser = Readonly<{
  subject: string;
  displayName: string;
  tenantId: string | null;
  uiPermissions: readonly string[];
}>;

export type SessionSnapshot = Readonly<{ epoch: number }> & (
  | Readonly<{ status: 'checking' }>
  | Readonly<{ status: 'anonymous' }>
  | Readonly<{ status: 'authenticated'; user: SessionUser }>
  | Readonly<{ status: 'signing-out' }>
  | Readonly<{
      status: 'unavailable';
      reason: 'network' | 'server' | 'logout-unconfirmed';
    }>
);

export interface SessionReader {
  getSnapshot(): SessionSnapshot;
  subscribe(listener: () => void): () => void;
}

export type LoginDestination =
  | Readonly<{ kind: 'home' }>
  | Readonly<{ kind: 'cart' }>;

export interface AuthCommands {
  startLogin(destination: LoginDestination): void;
  revalidate(): Promise<void>;
  logout(): Promise<void>;
}

export interface AuthPort extends SessionReader, AuthCommands {}

Các semantics cần đi cùng type:

  • getSnapshot() trả cùng reference khi chưa đổi; snapshot và object/array lồng bên trong là immutable theo contract. Readonly chỉ bảo vệ ở mức TypeScript, owner vẫn phải giữ immutability lúc runtime.
  • subscribe() thông báo snapshot đã đổi và trả hàm cleanup. Consumer đọc lại snapshot, không dựng state bằng cách đếm event login/logout.
  • epoch tăng khi scope cũ bị vô hiệu hóa: bắt đầu logout, session hết hiệu lực, đổi identity/tenant hoặc bắt đầu một phiên khác. Logout rồi login lại cùng user cũng phải có epoch mới.
  • Revalidation xác nhận cùng scope hoặc refresh credential cho cùng session không nhất thiết tăng epoch. Nếu không chứng minh được sự liên tục của scope, owner vô hiệu hóa scope cũ theo hướng bảo thủ.
  • startLogin() yêu cầu auth owner bắt đầu luồng; nó không báo rằng login đã thành công. Full-page redirect có thể kết thúc runtime hiện tại. Owner deduplicate yêu cầu login đồng thời trong cùng runtime.
  • revalidate() cập nhật snapshot và resolve khi kiểm tra xong, kể cả kết quả anonymous; lỗi transport reject và publish unavailable theo chính sách. Consumer không suy ra authenticated chỉ từ việc Promise resolve.
  • logout() khóa scope cũ ngay, rồi resolve khi backend xác nhận session của app đã kết thúc. Nếu không xác nhận được, Promise reject và snapshot vẫn không cho phép tải dữ liệu riêng tư.

uiPermissions là projection để render, không phải giấy phép truy cập API. Mình chỉ đưa các field UI thật sự cần vào snapshot; email, toàn bộ claims hay hồ sơ khách hàng không nên phát cho mọi remote nếu không có nhu cầu cụ thể.

Consumer dựng UI từ trạng thái hiện tại

Adapter độc lập framework dưới đây cho remote biết khi nào nên chờ, yêu cầu login hoặc tải dữ liệu. show do remote cung cấp; không có dependency vào auth SDK hay store của shell.

type PrivateView = 'loading' | 'login' | 'content' | 'retry' | 'locked';

export function connectPrivateView(
  session: SessionReader,
  show: (view: PrivateView) => void,
): () => void {
  const update = () => {
    const snapshot = session.getSnapshot();

    switch (snapshot.status) {
      case 'checking':
        show('loading');
        break;
      case 'anonymous':
        show('login');
        break;
      case 'authenticated':
        show('content');
        break;
      case 'unavailable':
        show('retry');
        break;
      case 'signing-out':
        show('locked');
        break;
    }
  };

  const unsubscribe = session.subscribe(update);
  update();
  return unsubscribe;
}

Subscribe trước rồi đọc snapshot ban đầu giúp remote mount muộn vẫn thấy state hiện tại mà không cần replay một event login. Khi unmount, gọi hàm cleanup. Với React có thể dùng useSyncExternalStore với snapshot ổn định và server snapshot phù hợp; không cần thêm một isLoggedIn riêng trong local store.

Đây là adapter hiển thị, không phải route guard hoàn chỉnh. content chỉ có nghĩa là được bắt đầu trải nghiệm đã login; API vẫn có thể trả 403, tài nguyên không tồn tại hoặc session vừa hết hạn. Việc mount/unmount vùng riêng tư và dọn cache khi epoch đổi thuộc lifecycle của remote.

Refresh và xử lý lỗi API

Nếu cart, header và account cùng gặp lỗi xác thực, để cả ba tự refresh sẽ tạo nhiều request cạnh tranh. Đặc biệt với refresh token rotation, một request dùng token cũ đến muộn có thể bị xem là reuse. Vì thế, refresh phải có owner ở đúng nơi giữ credential.

Phân biệt hết phiên thiếu quyền và lỗi mạng

Kết quảCách hiểu trong contract API thông thườngHành động mặc định
401 do credential hết hạn hoặc không hợp lệRequest chưa được xác thực hợp lệAuth owner xác định có thể renew hay phải revalidate/kết thúc session
403 do thiếu quyềnKhông được thực hiện hành độngHiện forbidden hoặc lỗi nghiệp vụ phù hợp; không tự refresh
Network error hoặc 5xxChưa biết request/session có hợp lệ khôngBáo lỗi kết nối, retry có giới hạn theo loại request
Refresh bị từ chối do token/session không còn hợp lệKhông thể tiếp tục session hiện tạiVô hiệu hóa scope và công bố cần login lại

Bảng giả định backend dùng semantics này; nếu API có error code như TOKEN_EXPIRED, TENANT_ACCESS_DENIED hoặc STEP_UP_REQUIRED, công bố chúng trong contract. Một 401 ở một service không luôn có nghĩa session của toàn app đã mất: token có thể sai audience hoặc service bị cấu hình sai. Nếu mọi lần 401 đều dẫn tới logout toàn app, lỗi integration sẽ trông như lỗi người dùng.

Với BFF, BFF có thể renew access token downstream mà browser không biết. Browser revalidate session của app khi cần, không tự gọi OAuth token endpoint. Nếu BFF đã kết luận session không hợp lệ, gọi lại một /auth/refresh từ từng remote không làm session sống lại.

Single flight và retry có giới hạn

Trong bearer model, transport/auth owner nên áp dụng single flight: các request cùng cần renew credential chờ một Promise refresh chung cho cùng scope. Không dùng một Promise global xuyên user ở SSR.

Một thuật toán ở mức thiết kế:

  1. Request ghi nhận scope epoch và credential version đang dùng. Transport chọn token đúng resource/audience.
  2. Nếu API trả lỗi có thể renew, kiểm tra scope còn active. Nếu credential version đã đổi, không refresh lại bằng credential cũ.
  3. Nếu chưa có refresh cho scope này, tạo một refresh Promise; các request khác cùng scope chờ Promise đó.
  4. Trước khi commit credential mới hoặc retry, kiểm tra epoch vẫn khớp. Logout hoặc đổi user phải vô hiệu hóa cả kết quả refresh đang chạy.
  5. Chỉ retry request đủ điều kiện và giới hạn, ví dụ tối đa một lần sau renew. Nếu vẫn bị từ chối, trả lỗi rõ ràng và revalidate theo contract, không chạy vòng lặp refresh.

Credential version và scope epoch không phải cùng một thứ. Refresh có thể đổi access token nhưng vẫn giữ cùng user/session; logout thì đổi scope, khiến mọi request cũ không còn được commit dù chúng có token mới.

Không replay mọi mutation

Request POST /checkout có thể đã tạo đơn hàng dù browser chỉ thấy timeout. Chỉ tự retry mutation khi backend cung cấp semantics an toàn, chẳng hạn idempotency key gắn với cùng thao tác và cùng scope. Cancellation hoặc refresh thành công không chứng minh request trước chưa chạy. Với đọc dữ liệu, vẫn cần kiểm tra scope trước khi commit response.

Single flight ở một tab không giải quyết refresh từ tab khác hoặc thiết bị khác. BFF có thể điều phối renewal ở backend theo session/grant; public-client SDK cần chiến lược cross-tab và xử lý rotation/reuse theo provider. Không coi một biến isRefreshing trong shell là giải pháp concurrency cho cả hệ thống.

Logout là một chuyển đổi phạm vi dữ liệu

Xóa access token hoặc đổi isLoggedIn = false chỉ là một phần của logout. Bạn còn có cart cache, draft địa chỉ, request đang chạy, dữ liệu trong DOM và các tab khác. Những thứ đó phải ngừng thuộc về phiên cũ.

Quy trình logout

Với mô hình BFF trong bài, mình dùng quy trình sau:

  1. Auth owner tăng epoch và publish signing-out ngay khi nhận command. Khóa request riêng tư mới, hủy refresh/request đang chạy khi có thể và thông báo các domain dọn state cũ.
  2. Cart/account dọn dữ liệu riêng tư và draft theo chính sách; shell không tiếp tục render nội dung của user cũ. Catalog public có thể giữ cache sản phẩm không cá nhân hóa.
  3. Gửi logout tới backend bằng request thay đổi trạng thái được bảo vệ CSRF. Backend invalidate session và expire cookie đúng name/path/domain ban đầu; không dùng GET để logout.
  4. Khi backend xác nhận, owner publish anonymous, shell chọn route an toàn và thông báo tab khác revalidate.
  5. Nếu logout không được xác nhận, publish unavailable với logout-unconfirmed. UI vẫn khóa dữ liệu riêng tư và cung cấp retry, không thông báo “đã đăng xuất thành công”.

HttpOnly cookie phải được backend expire; JavaScript không xóa được nó qua document.cookie. Nếu mất mạng, server session có thể còn hợp lệ. Đừng tự revalidate rồi phục hồi authenticated ngay sau lỗi logout mà bỏ qua ý định đăng xuất của người dùng; cần hoàn tất retry hoặc một hành động khôi phục được giải thích rõ.

Cũng cần chặn luồng login mới đua với logout chưa hoàn tất: response logout cũ có thể expire cookie của lần login mới. Backend và SDK cần lifecycle/transaction phù hợp; một epoch ở UI chỉ chặn commit state frontend, không chặn mọi side effect Set-Cookie từ response network cũ.

Logout khỏi app và logout khỏi identity provider là hai việc khác nhau. Session app đã bị revoke nhưng SSO session ở provider vẫn còn thì lần login sau có thể không yêu cầu password. Logout toàn provider hoặc toàn thiết bị phải là lựa chọn có contract và cấu hình riêng; đừng hứa “đã đăng xuất ở mọi nơi” khi chỉ xóa session hiện tại.

Trong bearer model, revoke refresh token/grant theo khả năng provider và xóa credential runtime. Access token tự chứa có thể còn được resource server chấp nhận tới expiry nếu không có cơ chế revocation bổ sung. Nêu rõ cửa sổ hiệu lực và cơ chế enforce phía backend, không coi việc xóa token ở browser là revoke token đã bị sao chép.

Chặn response của session cũ

Hình dung user A đang tải cart, logout, rồi user B login trước khi response cart của A về. Query key có userId giúp tách cache nhưng chưa đủ nếu callback vẫn ghi vào store hiện tại. Cần kiểm tra epoch tại thời điểm commit, sau cả bước parse response.

Ví dụ dưới đây chỉ minh họa một lần tải cart cho scope đã login. Transport CartApi đã được cấu hình cho endpoint tin cậy và tự xử lý credential; consumer không đọc token.

export type CartDto = Readonly<{
  items: readonly Readonly<{ sku: string; quantity: number }>[];
}>;

export interface CartApi {
  getCart(signal: AbortSignal): Promise<CartDto>;
}

export function loadCartForCurrentSession(
  session: SessionReader,
  api: CartApi,
  commit: (cart: CartDto) => void,
  reportError: (error: unknown) => void,
): () => void {
  const initial = session.getSnapshot();
  if (initial.status !== 'authenticated') return () => {};

  const epoch = initial.epoch;
  const controller = new AbortController();

  const isCurrent = () => {
    const current = session.getSnapshot();
    return !controller.signal.aborted
      && current.status === 'authenticated'
      && current.epoch === epoch;
  };

  const unsubscribe = session.subscribe(() => {
    if (!isCurrent()) controller.abort();
  });

  void (async () => {
    try {
      const cart = await api.getCart(controller.signal);
      if (isCurrent()) commit(cart);
    } catch (error) {
      if (isCurrent()) reportError(error);
    } finally {
      unsubscribe();
    }
  })();

  return () => {
    controller.abort();
    unsubscribe();
  };
}

CartApi.getCart() resolve sau khi response đã được parse và validate theo schema của API. Hàm unsubscribe cần idempotent để cleanup khi hoàn tất rồi unmount vẫn an toàn. Nếu scope đổi, caller dọn cart đang hiển thị và chỉ khởi động lần tải mới cho scope mới khi đã được xác nhận; helper này không tự quản lý cả store cart.

AbortController giúp giảm công việc thừa, nhưng kiểm tra epoch mới chặn response đến muộn. Abort không bảo đảm backend dừng mutation và không thu hồi response đã hoàn tất. Khi route đổi từ cart này sang cart khác trong cùng epoch, domain còn cần query key hoặc request generation riêng; session epoch không thay thế mọi cơ chế chống stale response.

Epoch chỉ phản ánh những thay đổi runtime đã biết. Một tab khác đổi session cookie mà tab hiện tại chưa nhận tín hiệu vẫn là khoảng trễ đồng bộ. Nếu cần ràng buộc chặt hơn, thiết kế thêm scope/version identifier do backend xác nhận trong response và validation phù hợp; không dùng epoch client làm bằng chứng authorization.

Cache và dữ liệu persist

Tách dữ liệu public khỏi dữ liệu theo user. Tên sản phẩm công khai có thể giữ qua logout; giỏ hàng, lịch sử đơn và giá cá nhân hóa thì không nên dùng lại cho user khác.

Dữ liệuScope cần xétKhi logout hoặc đổi user
Session projectionRuntime session epochThay snapshot, không hydrate thành phiên hợp lệ từ storage
Cart và đơn hàngSubject, tenant, session epoch hoặc namespace tương đươngCancel, invalidate/remove cache và chặn stale commit
Draft địa chỉ/thanh toánSubject, tenant, TTL và mức nhạy cảmXóa hoặc khóa theo chính sách đã công bố
Catalog publicProduct, locale, version dữ liệuCó thể giữ nếu không chứa dữ liệu cá nhân hóa
Catalog cá nhân hóaSubject/tenant và các biến query liên quanKhông dùng chung cache với public catalog

Nếu persist draft, đừng lưu payment credential, access token hay cả auth store để “khôi phục phiên”. Persisted identity cũng không chứng minh session còn hợp lệ sau reload. Với service worker hoặc offline cache, kiểm tra riêng response authenticated có bị giữ lại và phục vụ cho phiên khác hay không.

Auth owner công bố scope đổi; domain owner chịu trách nhiệm dọn query cache, optimistic state và draft của domain mình. Shell không cần biết mọi query key để logout, nhưng integration contract cần buộc remote thực hiện cleanup dù màn hình domain chưa mount hoặc đã unmount. Xem thêm state ownership.

UI permissions không thay backend authorization

Giả sử snapshot có cart.checkout. Cart dùng giá trị đó để hiện nút thanh toán, nhưng backend vẫn cần kiểm tra request đang thuộc user nào, cart nào, tenant nào và nghiệp vụ còn hợp lệ không.

Kiểm traFrontend dùng đểBackend phải làm
User đã loginChọn loading/login/contentValidate session hoặc credential
Có permission checkoutHiện nút hoặc hướng dẫn xin quyềnEnforce policy cho hành động checkout
Cart thuộc user hiện tạiTránh trình bày dữ liệu sai scopeKiểm tra ownership tài nguyên, chống truy cập cart của user khác
Tenant đang chọnDựng navigation và query scopeKiểm tra user thật sự được truy cập tenant, không tin tenant ID browser gửi
Giá và tồn kho hợp lệHiển thị dự kiếnTính và validate lại lúc mutation

Route guard cũng chỉ là kiểm soát trải nghiệm. Người dùng có thể gọi API trực tiếp mà không qua route. Ngược lại, user có permission trong snapshot cũ vẫn có thể nhận 403 sau khi admin thu hồi quyền; UI cần xử lý kết quả đó và revalidate projection khi thích hợp.

Đừng nhúng policy backend thành một bản kiểm tra chi tiết trong mỗi remote. Bạn sẽ có nhiều bản policy khác version và không biết bản nào đúng. Frontend dùng capability/projection ổn định cho UI; backend giữ quyết định theo tài nguyên và ngữ cảnh hiện tại. Feature flag bật checkout cũng không phải permission checkout.

Với thao tác cần xác thực mạnh hơn, như đổi thông tin nhạy cảm, backend có thể yêu cầu step-up/MFA hoặc recent authentication. Auth owner điều phối challenge theo error contract; remote không tự gọi login thông thường rồi giả định yêu cầu đã được đáp ứng.

Cookie session được browser gửi tự động, nên endpoint thay đổi trạng thái cần CSRF protection. HttpOnly ngăn đọc cookie bằng JavaScript, không ngăn browser gửi cookie và không phải biện pháp chống CSRF.

Với BFF, dùng cơ chế được framework hỗ trợ như synchronizer token hoặc signed double-submit phù hợp, kết hợp kiểm tra Origin/Referer hoặc Fetch Metadata theo chính sách backend. Nếu dùng CSRF token header, chỉ gắn token vào endpoint tin cậy; backend phải validate, không chỉ kiểm tra có header. SameSite là một lớp phòng vệ, không thay toàn bộ CSRF protection.

Login, logout và callback cũng có yêu cầu chống giả mạo transaction riêng. Không có mutation qua GET. Không thiết kế một proxy BFF nhận URL tùy ý rồi chuyển tiếp cookie/token sang destination đó; cần allowlist endpoint và giới hạn quyền downstream.

BFF không sandbox remote

Remote bị compromise trong document của shell có thể gọi endpoint bằng session của browser, kể cả khi không đọc được cookie. Auth contract chỉ giới hạn API được công bố, không giới hạn quyền của JavaScript cùng realm. Cần kiểm soát nguồn remote, supply chain, CSP và XSS; với code không đáng tin, cân nhắc isolation bằng iframe khác origin và protocol có giới hạn.

Không ghi credential vào event bus, console, analytics, error report hay URL. Log cả header Authorization hoặc response session nguyên vẹn thường lộ nhiều dữ liệu hơn bạn cần để debug. Chỉ thu thập field đã cho phép và redact ở cả frontend lẫn backend.

Nhiều tab iframe và standalone

Những runtime này không chia sẻ một object auth store. “Một owner” nghĩa là có quy tắc điều phối nhất quán, không phải mọi browser tab có cùng một singleton JavaScript.

Bối cảnhThiết kế nên bắt đầu từBẫy cần tránh
Nhiều tab cùng originMỗi tab có auth owner; notification khiến tab khác khóa/revalidate snapshotBroadcast raw token hoặc coi payload login của tab khác là xác nhận session
Iframe khác originChild có session/credential flow riêng hoặc capability bridge hẹp đã reviewGửi token bằng postMessage('*') hoặc tin mọi message từ parent/child
Remote chạy standaloneInject adapter dev/test có cùng contract, hoặc dùng auth thật cho môi trường đóDùng một isLoggedIn mock độc lập với kiểu trạng thái integrated

Với nhiều tab, BroadcastChannel có thể phát tín hiệu “session có thể đã đổi”. Tab nhận không tự copy user/permissions từ message thành authenticated; nó revalidate từ nguồn chuẩn. Với tín hiệu logout, có thể vô hiệu hóa scope và khóa dữ liệu ngay, rồi xác nhận session. Notification không phải kênh tin cậy cho credential và không bảo đảm giao nhận khi tab bị đóng băng; revalidate khi tab trở lại foreground theo chính sách phù hợp.

Với iframe khác origin, kiểm tra event.origin, event.source và schema/version của message, đồng thời dùng targetOrigin cụ thể. Nếu parent cung cấp capability cho child, giới hạn command, destination và dữ liệu trả về; parent phải kiểm tra quyền ở backend. Chỉ validate origin không làm mọi request từ code trên origin đó được phép.

Iframe không thể mặc định đọc cookie của parent. Cookie SameSite=None; Secure cũng không bảo đảm third-party cookies được chấp nhận trên browser mục tiêu. Silent login trong iframe phụ thuộc chính sách browser/provider; cần fallback qua top-level redirect hoặc cơ chế embedding phù hợp. Partitioned cookies và Storage Access API có semantics riêng, không phải cách bật lại một session chung cho mọi site.

SSO giữa hai ứng dụng không đồng nghĩa chia sẻ app session: hai app có thể cùng provider nhưng mỗi app có cookie session, audience và policy riêng. Standalone cart không nên dùng production refresh token copy từ shell để tiện debug. Dùng test identity và cấu hình redirect URI/CORS của môi trường dev; mock adapter chỉ dành cho local/test và không được bật nhầm trong production.

SSR và hydration

Với SSR, session thuộc request đang xử lý. Không giữ user, cookie hoặc token trong mutable singleton cấp process: request của user B có thể đọc state của user A nếu chúng dùng chung object.

Server đọc session từ request, dựng projection tối thiểu rồi render theo scope đó. Không serialize access token, refresh token hoặc session ID vào HTML. Nếu server component gọi API qua BFF, chỉ forward credential tới destination tin cậy, không chuyển toàn bộ cookie header sang mọi host.

Browser bootstrap auth runtime từ server snapshot tương thích với HTML rồi revalidate theo chính sách. Server render authenticated nhưng client khởi đầu anonymous dễ gây hydration mismatch, nhấp nháy login và redirect nhầm. Ngược lại, snapshot hydrated không cho phép bỏ qua việc API kiểm tra session thực tế.

Không cache HTML riêng tư ở shared CDN như HTML public. Với Next.js/server data cache, lựa chọn cache phải đúng semantics phiên bản framework đang dùng và tách theo scope hoặc không cache dữ liệu session. Cache key thiếu user/tenant có thể lộ dữ liệu dù auth handler hoàn toàn đúng. Chi tiết framework nằm ở Next.js và SSR.

Failure modes và cách debug

Bắt đầu từ auth snapshot của owner, scope của request và kết quả backend, không bắt đầu bằng việc decode một JWT rồi suy ra toàn hệ thống đúng. JWT có claim chưa hết hạn không chứng minh token đúng issuer/audience hoặc session chưa bị revoke.

Triệu chứngNghi ngờ đầu tiênCách kiểm tra
Header đã login nhưng cart chưa loginHai auth instance hoặc remote giữ boolean riêngTrace instance wiring và snapshot cart đọc khi mount
Mở deep link thì redirect login trước khi bootstrap xongchecking bị xử lý như anonymousGhi timeline status và guard decision
Login redirect lặp sau callbackGuard chặn callback, session chưa được set hoặc không gửi cookieKiểm tra route callback, cookie attributes và session API
Login xong nhưng API luôn 401Sai audience/issuer, cookie/CORS hoặc transport configĐối chiếu API error code và cấu hình credential, không dump token
API 403 làm logout toàn appInterceptor gom mọi lỗi thành auth failureTrace mapping từ response tới auth command
Nhiều refresh chạy cùng lúcRefresh nằm trong từng remote hoặc single flight sai scopeĐếm refresh theo session/grant, tab và credential version
Logout xong xuất hiện lại cart cũResponse/refresh cũ commit muộnKiểm tra epoch lúc request bắt đầu và lúc commit
User B thấy dữ liệu ACache key thiếu scope, persist cũ hoặc SSR singletonTrace query namespace, cleanup và request-local state
Tab khác vẫn thấy private UI sau logoutChưa nhận tín hiệu hoặc chưa revalidateKiểm tra notification và foreground policy
Iframe login được ở dev nhưng lỗi productionThird-party cookie hoặc provider policy khácKiểm thử bằng browser mục tiêu, không chỉ đổi CORS
Logout báo thành công nhưng reload lại có sessionChỉ dọn frontend, revoke lỗi hoặc cookie expire sai scopeKiểm tra acknowledgement backend và session API sau reload

Log request correlation ID, auth operation, status transition, epoch cục bộ và error category thường đủ để dựng timeline. Khi cần liên kết backend, dùng identifier đã được review và pseudonymize phù hợp; đừng log raw session ID, cookie, token, authorization code hoặc thông tin user đầy đủ. Phân biệt rõ “renew credential”, “revalidate session” và “logout” trong telemetry để tránh gom mọi vấn đề thành “login failed”.

Ma trận kiểm thử

Unit test state machine và contract giúp bắt lỗi mapping; các tình huống cookie, redirect, nhiều tab và response đến muộn cần integration/E2E test. Dùng test identity riêng và cho phép điều khiển độ trễ response để tái hiện race.

Kịch bảnKết quả cần kiểm tra
Reload route bảo vệ khi session còn hợp lệChờ bootstrap, không redirect login sớm, dựng đúng user
Session API trả network error hoặc 5xxHiện retry, không kết luận anonymous
Remote mount sau khi login hoàn tấtĐọc snapshot hiện tại, không phụ thuộc event cũ
Hai remote cùng yêu cầu loginMột luồng login được điều phối, không ghi đè transaction tùy tiện
User hủy login hoặc callback invalidBáo lỗi/cancel rõ ràng, không redirect lặp
Return URL ngoài origin hoặc ngoài route allowlistBackend và frontend đều từ chối, dùng fallback an toàn
Nhiều request gặp credential hết hạnMột refresh cho scope trong runtime; retry có giới hạn
Refresh thất bại do network và do token bị revokeHai kết quả được phân loại khác nhau, không refresh vô hạn
API domain trả 403Hiện thiếu quyền, không tự logout/login lại
Checkout timeout sau khi backend đã xử lýKhông tạo đơn trùng; retry theo contract idempotency
Logout trong lúc cart và refresh còn pendingDọn dữ liệu ngay, kết quả cũ không commit hoặc phục hồi credential
Logout backend không phản hồiUI khóa dữ liệu và báo chưa xác nhận logout; retry được
Logout rồi login lại cùng user hoặc user BEpoch mới; cache/response cũ không cập nhật scope mới
Đổi tenant khi query đang chạyHủy hoặc bỏ qua kết quả tenant cũ; backend kiểm tra tenant mới
Tab A logout, tab B quay lại foregroundB khóa/revalidate dữ liệu, không tin snapshot cũ vô hạn
Mount/unmount remote nhiều lầnKhông tăng listener hoặc cập nhật instance đã hủy
Request mutation không có CSRF protection hợp lệBackend từ chối dù browser có cookie session
Gọi trực tiếp API với cart của user khácBackend từ chối dù UI đã ẩn nút/link
Iframe dưới chính sách chặn third-party cookiesCó luồng fallback đã công bố; không phát token ra message tùy ý
SSR đồng thời cho hai test userHTML và dữ liệu không lẫn session; hydrate không nhấp nháy login
Shell và remote khác version còn hỗ trợSnapshot/commands tương thích, status lạ được xử lý theo contract

Thêm test logout khi network chậm ngay trước login mới: kiểm tra cả snapshot lẫn cookie sau khi các response hoàn tất. Test epoch ở frontend không thể tự chứng minh backend session lifecycle đúng.

Checklist trước khi deploy

  • Auth owner và lifecycle instance đã rõ; remote không tự tạo auth store khác trong integrated mode.
  • Phân biệt checking, anonymous, authenticated, lỗi xác nhận và logout đang chạy.
  • Public contract có snapshot hiện tại, subscription cleanup và command semantics, không chứa credential.
  • Login/callback dùng SDK phù hợp, validate transaction và return destination ở backend.
  • Credential chỉ đi tới endpoint/resource đã cho phép; refresh có owner và giới hạn retry.
  • Backend enforce session, authorization, resource ownership và tenant cho từng request.
  • Cookie có scope/attributes phù hợp; mutation dùng CSRF protection thực sự ở backend.
  • Logout revoke session phía server; lỗi logout không bị trình bày thành thành công.
  • Đổi session/tenant dọn private cache và chặn stale response, kể cả refresh đang chạy.
  • Cross-tab, iframe và standalone có contract riêng; không broadcast hoặc persist raw token để tiện đồng bộ.
  • SSR không dùng mutable user singleton; HTML/session response không bị cache công khai.
  • Telemetry đã redact credential và dữ liệu nhạy cảm; test bao phủ cả phiên còn sống lẫn lỗi mạng.

Trước khi nối thêm remote, hãy chạy một tình huống cụ thể: login bằng user A, giữ request cart pending, logout rồi login bằng user B, sau đó cho response của A hoàn tất. Nếu UI không lộ dữ liệu A và backend không chấp nhận request ngoài scope được phép, bạn đã kiểm tra được hai ranh giới khác nhau: lifecycle frontend và authorization backend.

Đọc tiếp

Tài liệu tham khảo

On this page