Start HereOrdered learning track

HTTP-only Cookie Auth

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

Desain HTTP-only cookie auth untuk React: server-side session, cookie attributes, SameSite, CSRF defense, bootstrap, logout, refresh, multi-tenant scope, BFF, dan failure model.

9 min read1793 words
PrevNext
Lesson 12130 lesson track01–24 Start Here
#react#authentication#session#cookies+5 more

HTTP-only Cookie Auth

HTTP-only cookie auth adalah salah satu pola paling kuat untuk React app yang butuh security, revocation, auditability, dan operational control.

Tetapi pola ini sering salah dipahami.

Kalimat yang benar:

HttpOnly cookie can prevent normal JavaScript from reading the session credential.

Kalimat yang salah:

HttpOnly cookie makes the app secure.

HttpOnly bukan sistem auth. HttpOnly bukan CSRF defense lengkap. HttpOnly bukan authorization. HttpOnly bukan session lifecycle. HttpOnly bukan revocation policy.

HttpOnly hanyalah satu atribut cookie yang memperbaiki satu boundary:

JavaScript tidak bisa membaca cookie value melalui document.cookie.

Part ini membangun desain cookie auth yang benar untuk React apps.


1. Mental model

Dalam HTTP-only cookie auth, React tidak memegang credential utama.

The browser stores the session cookie. React reads a session view, not the session credential. Server remains the authority.

Cookie = transport/session handle
/session = session view
/permissions = capability view
API/server = enforcement boundary
React = exposure and orchestration layer

For high-value apps, prefer an opaque session id in the cookie.

Set-Cookie: __Host-session=sid_7fj29...; Path=/; Secure; HttpOnly; SameSite=Lax

The server maps session id to server-side record:

session_id -> {
  user_id,
  account_id,
  tenant_memberships,
  authentication_time,
  mfa_level,
  issued_at,
  idle_expires_at,
  absolute_expires_at,
  revoked_at,
  device_id,
  risk_flags,
  current_refresh_token_family,
  last_seen_at
}

This gives you:

- revocation,
- idle timeout,
- absolute timeout,
- device/session list,
- suspicious session detection,
- forced logout,
- auditability,
- permission re-evaluation,
- tenant scoping.

Avoid putting large auth state directly in cookie:

- roles,
- permissions,
- tenant list,
- feature access,
- PII,
- internal risk state,
- workflow state.

Reasons:

1. Cookie is sent on matching requests, increasing request size.
2. Claims become stale unless refreshed carefully.
3. Revocation is harder if server does not keep state.
4. Sensitive data can leak through logs/proxies if mishandled.
5. Authorization still must be checked server-side.
6. Cookie size limits become operational constraint.

A signed/encrypted cookie can be valid for some architectures, but it trades server lookup for stale-state and revocation complexity. For regulatory or enterprise authorization, opaque server-side session is usually easier to reason about.


A production session cookie baseline:

Set-Cookie: __Host-session=<opaque-session-id>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800

For persistent “remember this device” style sessions, do not simply make the main session cookie long-lived. Use a separate, carefully rotated, server-side remembered-device/session-renewal mechanism.

3.1 HttpOnly

HttpOnly prevents normal JavaScript access:

// Cookie with HttpOnly will not be visible here.
console.log(document.cookie);

Benefit:

XSS cannot trivially read and exfiltrate the session id as a string.

Limit:

XSS can still perform actions through the authenticated browser.

If attacker can run JavaScript in your origin, they can call:

fetch("/api/admin/users/123/disable", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": await csrfTokenProvider.get()
  },
  body: JSON.stringify({ reason: "compromised" })
});

Therefore:

HttpOnly reduces credential theft.
It does not remove the need for XSS prevention and action-level authorization.

3.2 Secure

Secure means cookie is sent only over HTTPS, with special localhost behavior in modern browsers.

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

Without Secure, session cookie can be exposed over plaintext HTTP in certain configurations.

Baseline:

Session cookies must be Secure outside local development.

3.3 SameSite

SameSite controls whether cookies are sent with cross-site requests.

Common values:

ValueBehaviorTypical use
StrictMost restrictive; cookie generally not sent on cross-site navigationHigh-security apps with limited external entry flows
LaxSent on same-site requests and some top-level navigationsCommon default for first-party web apps
NoneSent in cross-site contexts, requires SecureEmbedded/third-party/cross-site flows

SameSite helps reduce CSRF, but it is not a universal replacement for CSRF tokens or origin checks.

3.4 Path

Path limits which request paths receive the cookie.

For __Host- cookies, Path=/ is required.

Path scoping is not a strong security boundary by itself, because any application running on the same origin can often trigger requests to paths on that origin. But it is still useful for reducing accidental cookie transmission.

3.5 Domain

Avoid broad Domain unless required.

Bad for high-value session cookies:

Set-Cookie: session=abc; Domain=.example.com; Path=/; Secure; HttpOnly; SameSite=Lax

This shares cookie across subdomains.

If legacy.example.com, blog.example.com, or preview.example.com are weaker, the session blast radius increases.

Prefer host-only cookie:

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

No Domain attribute.

3.6 Max-Age and Expires

Use explicit lifetime semantics:

Set-Cookie: __Host-session=abc; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800

But remember:

Cookie expiry is client-side storage behavior.
Server session expiry is authoritative.

Server must reject expired session even if browser still sends cookie.


For session cookies, prefer __Host- where possible.

Set-Cookie: __Host-session=<sid>; Path=/; Secure; HttpOnly; SameSite=Lax

Why this is useful:

- no Domain attribute allowed,
- host-only binding,
- requires Secure,
- requires Path=/,
- reduces subdomain cookie injection risk.

Modern cookie specs and MDN also describe __Http- and __Host-Http- style prefixes, where supported semantics prove cookie was set with Set-Cookie and HttpOnly. Browser support and deployment details should be checked before relying on newer prefixes as hard requirements. __Host- remains a widely recognized baseline for host-bound secure cookies.


5. Basic server flow

5.1 Login

Example Express-like pseudocode:

app.post("/auth/login", async (req, res) => {
  const result = await authenticate(req.body);

  if (!result.ok) {
    await audit("login.failed", { reason: result.reason });
    return res.status(401).json({ code: "INVALID_CREDENTIALS" });
  }

  const session = await sessionStore.create({
    userId: result.user.id,
    authTime: new Date(),
    mfaLevel: result.mfaLevel,
    userAgent: req.get("user-agent"),
    ipHash: hashIp(req.ip),
    idleExpiresAt: minutesFromNow(30),
    absoluteExpiresAt: hoursFromNow(12)
  });

  res.cookie("__Host-session", session.id, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 30 * 60 * 1000
  });

  await audit("login.succeeded", { userId: result.user.id, sessionId: session.id });

  res.status(204).end();
});

5.2 Session bootstrap

React should not decode cookie.

React should ask server:

export async function fetchSession(): Promise<SessionView> {
  const response = await fetch("/session", {
    method: "GET",
    credentials: "include",
    headers: { "Accept": "application/json" }
  });

  if (response.status === 401) {
    return { status: "anonymous" };
  }

  if (!response.ok) {
    throw new Error("Failed to fetch session");
  }

  return response.json();
}

Session response should be a view:

{
  "status": "authenticated",
  "user": {
    "id": "usr_123",
    "displayName": "Ari",
    "email": "ari@example.com"
  },
  "tenant": {
    "id": "tenant_456",
    "name": "Acme Regulated Ops"
  },
  "assurance": {
    "mfa": true,
    "level": "aal2"
  },
  "expiresAt": "2026-07-08T10:30:00.000Z",
  "permissionsVersion": "perm_v19"
}

Do not return:

{
  "sessionId": "sid_secret",
  "refreshToken": "rt_secret",
  "allInternalRiskFlags": ["..."],
  "allTenantsIncludingSuspended": ["..."]
}

5.3 Authenticated API call

await fetch("/api/cases/123", {
  credentials: "include",
  headers: { "Accept": "application/json" }
});

For mutations:

await fetch("/api/cases/123/approve", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken
  },
  body: JSON.stringify({ comment: "Reviewed and approved." })
});

6. CSRF defense is not optional

Cookie auth changes dominant risk.

With bearer token in Authorization header:

Browser does not automatically attach token to cross-site form/image/script request.

With cookie auth:

Browser may automatically attach cookie to matching requests.

Therefore state-changing endpoints need CSRF defense.

6.1 Defense layers

Use layers, not one trick.

6.2 SameSite

Set SameSite=Lax or Strict where possible.

Set-Cookie: __Host-session=<sid>; Path=/; Secure; HttpOnly; SameSite=Lax

Lax is often a pragmatic default for first-party web apps.

Strict can break flows where user arrives from external links and expects to remain logged in on initial navigation.

None should be reserved for cross-site/embedded use cases and must use Secure.

6.3 CSRF token pattern

A common pattern:

  1. Server issues HttpOnly session cookie.
  2. Server also exposes a CSRF token endpoint or non-HttpOnly CSRF cookie.
  3. React reads CSRF token and sends it in a custom header for unsafe methods.
  4. Server validates token is bound to session-specific data.

Example:

async function csrfFetch(input: RequestInfo, init: RequestInit = {}) {
  const method = (init.method ?? "GET").toUpperCase();
  const headers = new Headers(init.headers);

  if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
    headers.set("X-CSRF-Token", await csrfTokenProvider.get());
  }

  return fetch(input, {
    ...init,
    credentials: "include",
    headers
  });
}

Server validation must be real. Do not merely check that header exists.

Bad:

if (!req.header("X-CSRF-Token")) {
  return res.status(403).end();
}

Better:

const valid = await csrf.verify({
  sessionId: req.session.id,
  token: req.header("X-CSRF-Token"),
  method: req.method,
  path: req.path
});

if (!valid) {
  await audit("csrf.rejected", {
    sessionId: req.session.id,
    path: req.path,
    method: req.method
  });

  return res.status(403).json({ code: "CSRF_INVALID" });
}

OWASP recommends binding CSRF tokens to session-specific data; HMAC-based signed double-submit is preferred over naive double-submit when using that pattern.

6.4 Origin and Referer verification

For state-changing requests, validate Origin where available:

function verifyOrigin(req: Request): boolean {
  const origin = req.headers.get("origin");

  if (!origin) {
    // Depending on endpoint and browser/client support, either reject
    // or fall back to Referer verification with caution.
    return false;
  }

  return origin === "https://app.example.com";
}

This is useful defense-in-depth.

Do not rely on CORS alone as CSRF defense.

CORS controls whether a browser exposes response to a cross-origin script. It does not by itself mean an unwanted state-changing request was never sent.


7. React integration pattern

A clean React cookie-auth integration has these pieces:

- SessionProvider
- useSession()
- csrfFetch/client wrapper
- route loader/auth guard
- permission store
- logout orchestrator
- query cache invalidation
- cross-tab logout propagation

7.1 Session provider

import { createContext, useContext, useEffect, useMemo, useState } from "react";

type SessionState =
  | { status: "loading" }
  | { status: "anonymous" }
  | { status: "authenticated"; session: SessionView }
  | { status: "error"; error: Error };

const SessionContext = createContext<SessionState>({ status: "loading" });

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<SessionState>({ status: "loading" });

  useEffect(() => {
    let cancelled = false;

    fetchSession()
      .then(session => {
        if (cancelled) return;
        setState(session.status === "authenticated"
          ? { status: "authenticated", session }
          : { status: "anonymous" }
        );
      })
      .catch(error => {
        if (!cancelled) setState({ status: "error", error });
      });

    return () => {
      cancelled = true;
    };
  }, []);

  return (
    <SessionContext.Provider value={state}>
      {children}
    </SessionContext.Provider>
  );
}

export function useSession() {
  return useContext(SessionContext);
}

For React Router Data/Framework mode, prefer loader-level auth for route data. Component provider is still useful for UI, but not sufficient as the only boundary.

7.2 Fetch wrapper

type ApiError = {
  code: string;
  message?: string;
};

export async function apiFetch<T>(input: RequestInfo, init: RequestInit = {}): Promise<T> {
  const method = (init.method ?? "GET").toUpperCase();
  const headers = new Headers(init.headers);

  headers.set("Accept", "application/json");

  if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
    headers.set("X-CSRF-Token", await csrfTokenProvider.get());
  }

  const response = await fetch(input, {
    ...init,
    credentials: "include",
    headers
  });

  if (response.status === 401) {
    authEvents.emit({ type: "SESSION_EXPIRED" });
    throw new AuthError("SESSION_EXPIRED");
  }

  if (response.status === 403) {
    const body = await response.json().catch(() => null) as ApiError | null;
    throw new ForbiddenError(body?.code ?? "FORBIDDEN");
  }

  if (!response.ok) {
    throw new HttpError(response.status);
  }

  return response.json();
}

7.3 Do not read cookies in React

Bad:

const session = getCookie("session");

If the session cookie is HttpOnly, this will not work. That is the point.

React should call /session.

const session = await fetchSession();

8. Logout flow

Logout must terminate both server session and client state.

Server:

app.post("/auth/logout", requireSession, requireCsrf, async (req, res) => {
  await sessionStore.revoke(req.session.id, {
    reason: "user_logout",
    actorUserId: req.session.userId
  });

  res.clearCookie("__Host-session", {
    path: "/",
    secure: true,
    httpOnly: true,
    sameSite: "lax"
  });

  await audit("logout.succeeded", {
    userId: req.session.userId,
    sessionId: req.session.id
  });

  res.status(204).end();
});

Client:

export async function logout() {
  try {
    await apiFetch<void>("/auth/logout", { method: "POST" });
  } finally {
    authStore.clear();
    permissionStore.clear();
    queryClient.clear();
    realtime.close("logout");
    broadcastAuthEvent({ type: "LOGOUT" });
    router.navigate("/login", { replace: true });
  }
}

8.1 Back button after logout

After logout, browser back button must not reveal sensitive data.

Defense:

- clear client query/cache on logout,
- no persistent sensitive cache,
- server cache-control for authenticated pages/API,
- route loaders re-check session,
- sensitive screens render from current session state, not stale props.

For authenticated API responses:

Cache-Control: no-store

For some data, more nuanced cache strategy may be acceptable, but regulated data should default to no-store unless reviewed.


9. Session renewal and refresh

Cookie auth can mean several things:

1. Traditional server session with idle timeout.
2. BFF holding OAuth tokens server-side.
3. Cookie containing encrypted session payload.
4. Cookie referencing session + refresh token family server-side.

React should not care which internal strategy is used.

React cares about session view:

{
  "status": "authenticated",
  "expiresAt": "2026-07-08T10:30:00Z",
  "renewable": true
}

9.1 Sliding session

Server can extend idle expiry when request is active.

Be careful:

- do not extend on every asset request,
- do not extend for background polling forever,
- respect absolute timeout,
- audit renewal anomalies,
- throttle Set-Cookie churn.

Example:

async function maybeRenewSession(req, res, session) {
  const now = Date.now();
  const renewAfterMs = 5 * 60 * 1000;

  if (now - session.lastRenewedAt.getTime() < renewAfterMs) return;
  if (now > session.absoluteExpiresAt.getTime()) return;

  const renewed = await sessionStore.renew(session.id, {
    idleExpiresAt: minutesFromNow(30)
  });

  res.cookie("__Host-session", renewed.id, cookieOptions());
}

9.2 BFF token refresh

If BFF stores OAuth refresh token server-side:

React never sees access/refresh token.

This reduces browser token exposure and centralizes refresh rotation handling.


10. 401 vs 403 behavior

Cookie auth apps often mishandle errors.

10.1 401 Unauthorized

Meaning:

No valid authenticated session for this request.

Client action:

- transition to anonymous/expired,
- clear sensitive caches,
- redirect to login when appropriate,
- preserve safe return URL,
- stop background activity.

10.2 403 Forbidden

Meaning:

Session may be valid, but action/resource is not allowed.

Client action:

- do not blindly logout,
- show forbidden/error state,
- refresh permission view if needed,
- expose reason/request-access path if product supports it.

Example:

if (error instanceof AuthError && error.code === "SESSION_EXPIRED") {
  clearSensitiveClientState();
  navigate(`/login?returnTo=${encodeURIComponent(currentSafePath())}`);
}

if (error instanceof ForbiddenError) {
  permissionStore.invalidate();
  showForbiddenBoundary(error.reason);
}

11. CORS and credentials

If React app and API are same-origin, cookie auth is simpler.

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

Request:

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

If cross-origin:

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

Then server must intentionally configure CORS and cookie scope.

Client:

fetch("https://api.example.com/me", {
  credentials: "include"
});

Server response must not use wildcard origin with credentials:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Cookie may need domain/scoping decisions, but broad .example.com cookies should be reviewed carefully.

11.1 Same-site vs same-origin

Important distinction:

same-origin = scheme + host + port
same-site = registrable domain concept + schemeful site rules

app.example.com and api.example.com are different origins but may be same-site.

This matters for:

- CORS,
- cookie SameSite behavior,
- CSRF risk,
- subdomain trust.

Do not treat same-site as equally trusted by default. A weak subdomain can still be a risk.


A common mistake in SaaS apps:

Session proves user identity, therefore tenant access is assumed.

Wrong.

Session proves a user is authenticated.

Tenant access must be resolved and checked per request.

Bad:

const tenantId = req.header("X-Tenant-Id");
const userId = req.session.userId;

// Directly query tenant data using client-provided tenantId.

Better:

const tenantId = parseTenant(req);
const membership = await tenantMemberships.findActive({
  userId: req.session.userId,
  tenantId
});

if (!membership) {
  return res.status(403).json({ code: "TENANT_ACCESS_DENIED" });
}

12.1 Tenant switch

React can keep last selected tenant as hint.

But server validates:

app.post("/session/tenant", requireSession, requireCsrf, async (req, res) => {
  const tenantId = req.body.tenantId;

  const membership = await memberships.requireActive({
    userId: req.session.userId,
    tenantId
  });

  await sessionStore.update(req.session.id, {
    activeTenantId: membership.tenantId,
    permissionsVersion: await permissions.versionFor(req.session.userId, tenantId)
  });

  await audit("tenant.switched", {
    userId: req.session.userId,
    tenantId
  });

  res.status(204).end();
});

Cookie does not need to include tenant id unless there is a clear reason. Prefer session store field.


13. API topology choices

13.1 Same-origin app server

Browser -> app.example.com -> API handlers

Best for:

- simple deployment,
- SSR/React Router framework modes,
- cookie auth,
- low CORS complexity.

13.2 BFF

Browser -> app.example.com/BFF -> internal APIs

Best for:

- OAuth tokens hidden from browser,
- complex API topology,
- server-side session revocation,
- regulated apps,
- audit and policy centralization.

13.3 Direct cross-origin API

Browser -> api.example.com

Possible, but requires careful:

- CORS,
- cookie domain/samesite,
- CSRF,
- preflight behavior,
- origin allowlist,
- API gateway security.

13.4 Third-party API from browser

Browser -> vendor API

Avoid putting high-value tokens in browser for this unless explicitly designed. Prefer backend token mediation.


14. Common implementation mistakes

The session id is safe from JS theft.
But mutation endpoints are exposed to CSRF/session riding.

Fix:

SameSite + CSRF token + Origin verification + server authz.
localStorage.setItem("user", JSON.stringify(user));

This creates stale identity and privacy risk.

Fix:

Fetch /session on bootstrap.
Cache in memory.
Clear on logout/session expiry.
Cookie says role=admin, so allow admin action.

Even signed cookies can go stale.

Fix:

Evaluate policy server-side against current source of truth.
Set-Cookie: session=abc; Domain=.example.com

Fix:

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

When broad domain is unavoidable, do an explicit subdomain threat review.

Mistake 5: frontend assumes logged-in because /session once succeeded

Session can be revoked after bootstrap.

Fix:

Treat every API 401 as authoritative.
Clear local state.
Stop background work.
Re-login.

Mistake 6: no server-side logout

Client clears state, but server session remains valid.

Fix:

POST /auth/logout revokes session and expires cookie.

Mistake 7: no cache cleanup

Cookie is gone, but React Query/Apollo/persisted cache still holds sensitive data.

Fix:

Clear auth store, permission store, query cache, realtime connections, and sensitive persisted cache.

15. Security headers and cache headers

Cookie auth should be paired with browser hardening.

Recommended baseline:

Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self'

For authenticated sensitive API responses:

Cache-Control: no-store

For pages with sensitive content:

Cache-Control: no-store

Do not blindly apply includeSubDomains HSTS if you do not control all subdomains and HTTPS readiness. That is an operational decision.


16. Production-grade session record

A minimal session table:

CREATE TABLE user_sessions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  active_tenant_id TEXT NULL,
  auth_time TIMESTAMP NOT NULL,
  mfa_level TEXT NOT NULL,
  issued_at TIMESTAMP NOT NULL,
  last_seen_at TIMESTAMP NOT NULL,
  idle_expires_at TIMESTAMP NOT NULL,
  absolute_expires_at TIMESTAMP NOT NULL,
  revoked_at TIMESTAMP NULL,
  revoked_reason TEXT NULL,
  user_agent_hash TEXT NULL,
  ip_hash TEXT NULL,
  permissions_version TEXT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_user_sessions_user_active
ON user_sessions (user_id, revoked_at, idle_expires_at);

Session validation:

async function requireSession(req, res, next) {
  const sid = req.cookies["__Host-session"];

  if (!sid) {
    return res.status(401).json({ code: "SESSION_MISSING" });
  }

  const session = await sessionStore.find(sid);

  if (!session) {
    return res.status(401).json({ code: "SESSION_UNKNOWN" });
  }

  const now = new Date();

  if (session.revokedAt) {
    return res.status(401).json({ code: "SESSION_REVOKED" });
  }

  if (session.idleExpiresAt <= now) {
    return res.status(401).json({ code: "SESSION_IDLE_EXPIRED" });
  }

  if (session.absoluteExpiresAt <= now) {
    return res.status(401).json({ code: "SESSION_ABSOLUTE_EXPIRED" });
  }

  req.session = session;
  next();
}

17. Permission integration

Cookie session establishes subject.

Permission engine decides action.

app.post("/api/cases/:caseId/approve", requireSession, requireCsrf, async (req, res) => {
  const caseRecord = await cases.findById(req.params.caseId);

  if (!caseRecord) {
    return res.status(404).json({ code: "CASE_NOT_FOUND" });
  }

  const decision = await policy.can({
    subject: { userId: req.session.userId },
    action: "case.approve",
    resource: {
      type: "case",
      id: caseRecord.id,
      tenantId: caseRecord.tenantId,
      state: caseRecord.state
    },
    context: {
      sessionId: req.session.id,
      mfaLevel: req.session.mfaLevel,
      activeTenantId: req.session.activeTenantId
    }
  });

  if (!decision.allow) {
    await audit("case.approve.denied", {
      userId: req.session.userId,
      caseId: caseRecord.id,
      reason: decision.reason
    });

    return res.status(403).json({
      code: "FORBIDDEN",
      reason: decision.reason
    });
  }

  const result = await cases.approve(caseRecord.id, {
    actorUserId: req.session.userId
  });

  await audit("case.approved", {
    userId: req.session.userId,
    caseId: caseRecord.id
  });

  res.json(result);
});

Notice:

- session cookie identifies session,
- server loads resource,
- server evaluates policy,
- React cannot grant itself permission.

18.1 Server tests

Test cases:

[ ] missing cookie returns 401
[ ] unknown session id returns 401
[ ] revoked session returns 401
[ ] idle expired session returns 401
[ ] absolute expired session returns 401
[ ] valid session allows /session
[ ] valid session but missing CSRF rejects mutation
[ ] invalid CSRF rejects mutation
[ ] valid session but forbidden resource returns 403
[ ] logout revokes session and clears cookie

18.2 Browser/E2E tests

Test cases:

[ ] session restored after reload
[ ] session not readable via document.cookie
[ ] logout clears UI and cache
[ ] back button after logout does not show sensitive data
[ ] another tab receives logout signal
[ ] 401 during background polling stops polling
[ ] 403 does not logout user
[ ] tenant switch refreshes permission view

18.3 Security tests

Test cases:

[ ] CSRF form post without token rejected
[ ] cross-origin fetch without proper origin rejected
[ ] open redirect in return URL rejected
[ ] cookie has Secure/HttpOnly/SameSite/Path attributes
[ ] no session id appears in JS-readable storage
[ ] no session id appears in logs/client errors

19. Operational observability

Track metrics:

- session creation rate
- login success/failure rate
- session renewal rate
- session revoked count
- idle expiry count
- absolute expiry count
- CSRF rejection count
- 401 by reason
- 403 by resource/action
- logout success/failure
- session lookup latency
- session store error rate

Example structured audit event:

{
  "event": "auth.session.revoked",
  "sessionIdHash": "sha256:...",
  "userId": "usr_123",
  "reason": "password_changed",
  "actor": "system",
  "at": "2026-07-08T10:00:00Z"
}

Do not log raw session ids.

Log hashed/correlated identifiers where needed.


20. Design decision matrix

RequirementCookie auth fit?Notes
Hide credential from JSStrongUse HttpOnly
Server-side revocationStrongUse opaque session store
SSR/BFFStrongNatural fit
Direct browser-to-third-party APIWeak/MediumUsually need token/BFF mediation
Cross-site embedded appComplexSameSite=None; Secure; third-party cookie constraints; consider CHIPS/partitioned cookies
Offline API accessWeakCookie session depends on server reachability
Fine-grained permissionStrongServer policy checks per request
Low backend complexityMedium/WeakNeeds session store/CSRF/logout handling
XSS token theft preventionStronger than JS token storageStill must prevent XSS-as-user
CSRF resistance by defaultMediumNeeds deliberate defenses

For enterprise, SaaS, regulated workflow, financial, healthcare, internal admin, or case management systems:

Use an opaque session id in a __Host- HttpOnly Secure SameSite cookie.
Keep access/refresh tokens server-side in BFF/session store.
Expose /session and /permissions as explicit contracts.
Use CSRF defense for unsafe methods.
Enforce authorization server-side on every resource/action.
Clear all sensitive client state on logout/expiry.
Instrument session lifecycle and denial reasons.

That design is not fashionable because it is old.

It is strong because it has clear trust boundaries.


22. Summary

HTTP-only cookie auth is not “just cookies”.

It is an architecture:

Browser stores an HttpOnly cookie.
Server maps cookie to session.
React fetches session view.
Server validates session on each request.
Server evaluates authorization on each action/resource.
CSRF defenses protect unsafe cookie-authenticated requests.
Logout revokes server session and clears client state.
Observability tracks lifecycle and failure modes.

The key invariant:

React never needs to know the session secret.

That one decision reduces token exfiltration risk and gives the backend control over revocation, expiry, tenant context, audit, and policy evaluation.

But cookie auth must be complete:

HttpOnly without CSRF defense is incomplete.
Cookie without server-side revocation is incomplete.
Session without authorization is incomplete.
Logout without cache cleanup is incomplete.
/session without typed failure handling is incomplete.

A top-tier implementation is not defined by the presence of HttpOnly.

It is defined by the consistency of its lifecycle, boundaries, and failure behavior.


References

  • OWASP Session Management Cheat Sheet — session identifiers, cookie attributes, expiration, renewal, invalidation.
  • OWASP Cross-Site Request Forgery Prevention Cheat Sheet — SameSite, synchronizer token, signed double-submit, Origin/Referer verification.
  • MDN Web Docs — Set-Cookie, HttpOnly, Secure, SameSite, cookie prefixes, Partitioned cookies.
  • OAuth 2.0 Security Best Current Practice, RFC 9700.
  • OAuth 2.0 for Browser-Based Applications guidance.
Lesson Recap

You just completed lesson 12 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.