Skip to content

Cookie & Session Management

Matías Valle Trapiella edited this page Apr 13, 2026 · 1 revision

Cookie & Session Management

Overview

The application uses a server-side session model. The browser never holds user data in a readable cookie — it only holds two opaque tokens set by the server:

Cookie Set by Purpose
sessionId POST /api/login, POST /api/register Opaque pointer to the server-side session in Redis
csrf_token GET /api/csrf-token Double-submit CSRF protection token

All session data (username, email) lives in Redis, keyed by the random sessionId. The frontend reads its own identity by calling GET /api/me, which looks up the session server-side and returns the data.


Table of Contents


Redis Connection — redis-client.js

redis-client.js creates a single shared ioredis client that the API Gateway uses for all session operations:

const redisClient = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  enableOfflineQueue: false,
});

enableOfflineQueue: false means that if Redis is temporarily unreachable, commands fail immediately instead of queuing up and firing all at once when the connection recovers. This makes failures fast and visible.

Connection errors are logged to stderr but do not crash the process:

redisClient.on('error', (err) => {
  console.error('Redis error:', err.message);
});

The client is exported as a default singleton and imported by users-service.js.

Environment variables

Variable Default Description
REDIS_HOST localhost Redis hostname
REDIS_PORT 6379 Redis port

Session Lifecycle

Storage format

Each session is stored in Redis as a JSON string under the key session:{sessionId}:

session:a3f8c2...  →  {"username":"alice","email":"[alice@example.com](mailto:alice@example.com)"}

The sessionId is a 64-character hex string generated with crypto.randomBytes(32). It is unpredictable and not derived from any user data.

Sessions expire after 30 minutes of inactivity (SESSION_TTL_SECONDS = 1800). Every call to GET /api/me resets the TTL (sliding window).

Session helpers

Three internal functions in users-service.js encapsulate all Redis session operations:

// Create: generate a random ID, store the payload, return the ID
async function createSession(userData) {
  const sessionId = crypto.randomBytes(32).toString('hex');
  await redisClient.setex(`session:${sessionId}`, SESSION_TTL_SECONDS, JSON.stringify(userData));
  return sessionId;
}

// Read: look up by ID, parse the JSON, return null if missing or expired
async function getSession(sessionId) {
  if (!sessionId) return null;
  const data = await redisClient.get(`session:${sessionId}`);
  return data ? JSON.parse(data) : null;
}

// Destroy: delete the key from Redis immediately
async function destroySession(sessionId) {
  if (sessionId) await redisClient.del(`session:${sessionId}`);
}

Login

POST /api/login forwards the credentials to the Auth service. On a 200 OK response:

  1. Calls createSession({ username, email }) to write the session to Redis.
  2. Sets the sessionId cookie on the response.
  3. Forwards the Auth service's JSON body to the client.
const sessionId = await createSession({ username: data.username, email: data.email });
res.cookie('sessionId', sessionId, SESSION_COOKIE_OPTIONS);

If the Auth service returns an error (e.g. wrong password), no session is created and the error is forwarded as-is.

Registration

POST /api/register follows the same pattern as login. On success from the Auth service, a session is immediately created so the user is logged in without needing a separate login step.

Session read — GET /api/me

Used by the frontend on every page load to restore session state:

app.get('/api/me', async (req, res) => {
  const sessionId = req.cookies.sessionId;
  if (!sessionId) return res.status(401).json({ error: 'Not authenticated' });

  const user = await getSession(sessionId);
  if (!user) return res.status(401).json({ error: 'Session expired or invalid' });

  await redisClient.expire(`session:${sessionId}`, SESSION_TTL_SECONDS); // sliding TTL
  res.json(user);
});

A 401 response means the session does not exist or has expired. The frontend (UserContext) interprets this as "not logged in" and sets user to null.

Logout — POST /api/logout

app.post('/api/logout', verifyCsrf, async (req, res) => {
  const sessionId = req.cookies.sessionId;
  await destroySession(sessionId);
  res.clearCookie('sessionId', { path: '/', httpOnly: true, sameSite: 'lax' });
  res.json({ message: 'Logged out' });
});

The session key is deleted from Redis immediately. The sessionId cookie is cleared on the client. Even if the cookie somehow persists in the browser, it will return a 401 on the next /api/me call because the key no longer exists in Redis.

Username update — POST /api/update-username

Updates the username both in Firestore (via the Auth service) and in the active Redis session:

// After the Auth service confirms the change:
await redisClient.setex(
  `session:${sessionId}`,
  SESSION_TTL_SECONDS,
  JSON.stringify({ ...user, username })
);

The existing TTL is not preserved — setex resets it to 30 minutes. This is intentional: an account change counts as activity.


CSRF Protection

CSRF (Cross-Site Request Forgery) protection prevents a malicious page from making authenticated requests on behalf of the user. The application implements the Double Submit Cookie pattern.

How it works

  1. The frontend calls GET /api/csrf-token. The server generates a 64-character hex token with crypto.randomBytes(32), sets it as an httpOnly cookie (csrf_token), and returns the same value in the JSON response body.
app.get('/api/csrf-token', (req, res) => {
  const csrfToken = crypto.randomBytes(32).toString('hex');
  res.cookie('csrf_token', csrfToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/'
  });
  res.json({ csrfToken });
});
  1. The frontend stores the body value and attaches it to every mutating request as the X-CSRF-Token header.
  2. The verifyCsrf middleware compares the cookie value (sent automatically by the browser) against the header value (sent explicitly by the frontend code). A malicious third-party page cannot set the X-CSRF-Token header because it cannot read the httpOnly cookie:
const verifyCsrf = (req, res, next) => {
  if (['GET', 'OPTIONS', 'HEAD'].includes(req.method)) return next();

  const cookieToken = req.cookies.csrf_token;
  const headerToken = req.headers['x-csrf-token'];

  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'Invalid or missing CSRF token' });
  }
  next();
};

GET, OPTIONS, and HEAD are exempt because they carry no side effects.

Where verifyCsrf is applied

Endpoint CSRF enforced
POST /api/login
POST /api/register
POST /api/logout
POST /api/update-username
GET /api/csrf-token — (issues the token)
GET /api/me — (read-only)
/api proxy (catch-all)
/game proxy

Cookie Security Options

Both cookies are configured with security options that vary between development and production.

sessionId cookie

const SESSION_COOKIE_OPTIONS = {
  httpOnly: true,                                   // JS cannot read this cookie
  secure: process.env.NODE_ENV === 'production',    // HTTPS only in production
  sameSite: 'lax',                                  // Sent on same-site + top-level cross-site nav
  path: '/',
  maxAge: SESSION_TTL_SECONDS * 1000                // 30 minutes in milliseconds
};

httpOnly: true is critical — it prevents any JavaScript running in the browser (including injected XSS payloads) from reading the session ID.

csrf_token cookie

The CSRF cookie is also httpOnly. This is intentional: the frontend does not read it directly — it reads the token from the JSON response body and stores it in memory. The browser sends the cookie automatically on subsequent requests, and the server compares cookie vs. header. A script on a third-party origin cannot read the httpOnly cookie, so it cannot forge the header.

sameSite: 'lax'

lax allows the cookie to be sent on top-level cross-site navigations (e.g. following a link) but blocks it on cross-site subresource requests (e.g. fetch or XMLHttpRequest from another origin). This is the right balance for a web app that needs to work across HTTP redirects while still blocking CSRF in the most common attack vectors.

secure: true in production means the cookies are only transmitted over HTTPS, preventing interception over plain HTTP.


Frontend — UserContext.tsx

The frontend never manages session cookies directly. Instead, UserContext keeps the session state in React memory and delegates all session operations to the API Gateway.

Initialization

On app mount, UserProvider calls refreshUser(), which hits GET /api/me:

const refreshUser = useCallback(async () => {
  const res = await fetch(`${API_URL}/api/me`, { credentials: 'include' });
  if (res.ok) setUser(await res.json());
  else setUser(null);
}, []);

useEffect(() => {
  void refreshUser();
}, [refreshUser]);

credentials: 'include' is required on every fetch call so the browser sends the sessionId and csrf_token cookies with cross-origin requests.

loading state

loading is true until the first /api/me response arrives. ProtectedRoute renders null during this window to avoid briefly redirecting a logged-in user to /login on page refresh.

logout()

Fetches a fresh CSRF token (cannot reuse the one from the form since it may have expired), then calls POST /api/logout:

const logout = useCallback(async () => {
  const token = await fetchCsrfToken();
  await fetch(`${API_URL}/api/logout`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'X-CSRF-Token': token }
  });
  setUser(null);
}, []);

setUser(null) runs in the finally block — the local state is cleared even if the server call fails.

updateUsername()

const updateUsername = useCallback(async (username: string) => {
  const token = await fetchCsrfToken();
  const res = await fetch(`${API_URL}/api/update-username`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
    body: JSON.stringify({ username })
  });
  if (!res.ok) {
    const data = await res.json().catch(() => ({}));
    throw new Error(data.error ?? 'Failed to update username.');
  }
  setUser(prev => prev ? { ...prev, username } : null);
}, []);

On success the local user state is updated in place — no need to call /api/me again.


Frontend — useCsrf.ts

Two exports for different use cases:

// Hook: fetches once on mount, returns the token reactively.
// Use in forms that need the token available before the user submits.
export function useCsrf(): string

// Imperative: fetches a fresh token on demand.
// Use in event handlers that run outside the render cycle (logout, username update).
export async function fetchCsrfToken(): Promise<string>

Both hit GET /api/csrf-token with credentials: 'include'. The server sets the csrf_token cookie and returns the value in the body. The caller stores the body value and attaches it to the next mutating request as X-CSRF-Token.


Full Request Flow Diagrams

Login

sequenceDiagram
    participant B as Browser
    participant G as Gateway (port 3000)
    participant A as Auth service (port 4001)
    participant R as Redis

    B->>G: GET /api/csrf-token
    G-->>B: csrf_token cookie · { csrfToken: "abc..." }
    Note over B,G: El cliente guarda el token para enviarlo en el header
    B->>G: POST /api/login · X-CSRF-Token: "abc..." · { email, password }
    G->>A: POST /login
    A-->>G: 200 { username, email }
    G->>R: SETEX session:{id}
    R-->>G: OK
    G-->>B: sessionId cookie · 200 { username, email }
Loading

Page reload (session restore)

sequenceDiagram
    participant B as Browser
    participant G as Gateway (port 3000)
    participant R as Redis

    B->>G: GET /api/me · sessionId cookie
    G->>R: GET session:{id}
    R-->>G: {"username":"alice","email":"alice@..."}
    G->>R: EXPIRE session:{id} 1800
    G-->>B: 200 { username, email }
Loading

Logout

sequenceDiagram
    participant B as Browser
    participant G as Gateway (port 3000)
    participant R as Redis

    B->>G: GET /api/csrf-token
    G-->>B: csrf_token cookie

    B->>G: POST /api/logout · X-CSRF-Token: "..." · sessionId cookie
    G->>R: DEL session:{id}
    R-->>G: OK
    G-->>B: clearCookie(sessionId) · 200 { message: "Logged out" }
Loading

Clone this wiki locally