-
Notifications
You must be signed in to change notification settings - Fork 2
Cookie & Session Management
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.
- Redis Connection — redis-client.js
- Session Lifecycle
- CSRF Protection
- Cookie Security Options
- Frontend — UserContext.tsx
- Frontend — useCsrf.ts
- Full Request Flow Diagrams
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.
| Variable | Default | Description |
|---|---|---|
REDIS_HOST |
localhost |
Redis hostname |
REDIS_PORT |
6379 |
Redis port |
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).
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}`);
}POST /api/login forwards the credentials to the Auth service. On a 200 OK response:
- Calls
createSession({ username, email })to write the session to Redis. - Sets the
sessionIdcookie on the response. - 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.
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.
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.
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.
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 (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.
- The frontend calls
GET /api/csrf-token. The server generates a 64-character hex token withcrypto.randomBytes(32), sets it as anhttpOnlycookie (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 });
});- The frontend stores the body value and attaches it to every mutating request as the
X-CSRF-Tokenheader. - The
verifyCsrfmiddleware 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 theX-CSRF-Tokenheader because it cannot read thehttpOnlycookie:
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.
| 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 |
❌ |
Both cookies are configured with security options that vary between development and production.
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.
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.
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.
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.
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 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.
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.
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.
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.
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 }
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 }
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" }