1. What It Is
is stateless — each request carries no memory of previous ones. Cookies and sessions are the two foundational mechanisms backends use to maintain state across requests.
: A small key-value string the server sends to the browser in a Set- response header. The browser automatically attaches it to every subsequent request to the same domain. Cookies live in the browser.
: Server-side storage (memory, database, Redis) that holds user state. The server gives the browser a ID (usually via a cookie), and looks up the state on every request using that ID.
Why it matters: almost every web application that requires login uses one or both. Understanding how they work — and where they fail — is essential for backend engineers.
A user logs into a web application. On the next request, the server needs to know who the user is. If the application uses sessions, what does the browser actually send to the server to enable this lookup?
2. How It Works in Practice
Cookie Flow
Client Server
| |
|-- POST /login --------------->|
| | validates credentials
|<-- 200 OK --------------------|
| Set-Cookie: session_id=abc; HttpOnly; Secure; SameSite=Lax
| |
|-- GET /dashboard ------------>|
| Cookie: session_id=abc | looks up session state
|<-- 200 OK --------------------|Session Flow
- User logs in.
- Server creates a record in Redis/DB:
{ session_id: "abc", user_id: 42, roles: ["admin"] }. - Server returns
Set-: session_id=abc. - On every subsequent request, the browser sends
: session_id=abc. - Server fetches the record and treats the user as authenticated.
Cookie Attributes
| Attribute | What It Does |
|---|---|
HttpOnly | JavaScript cannot read the cookie — blocks XSS token theft |
Secure | Cookie sent only over HTTPS |
SameSite=Strict | Cookie not sent on cross-site requests — blocks CSRF |
SameSite=Lax | Sent on same-site requests and on cross-site top-level navigations with a safe method like GET (safe default) |
SameSite=None; Secure | Always sent cross-site (required for embedded widgets) |
Expires / Max-Age | Session (until the browser closes) vs. persistent cookie |
Domain | Which domain/subdomains receive the cookie |
Path | Limits cookie to a URL path |
The cookie carries an ID, the store keeps the state
The browser receives a session ID after login and sends it on the next request. The server uses that ID to fetch the user state from Redis or a database.
The browser sends a login request so the server can validate the credentials.
Text version
- The browser sends a login request so the server can validate the credentials.
- The server stores user forty-two and the admin role under session ID abc.
- The cookie carries only the session ID because the user state stays on the server.
- The browser automatically sends the session cookie when it requests the dashboard.
- The server uses the cookie's ID to fetch the matching session record.
- The returned session record identifies the user, so the server treats the request as authenticated.
A web app needs to protect its session cookie from being stolen via an XSS vulnerability where injected JavaScript tries to read document.cookie. Which cookie attribute directly prevents this attack?
3. Common Real-World Usage
Traditional web apps (server-rendered): server with an opaque ID is the standard. Rails uses ActionDispatch::Session, Django uses the django.contrib.sessions framework, Express uses express-session.
APIs + SPAs: JWTs stored in memory or HttpOnly cookies are more common than server sessions because they are stateless and scale without a shared session store.
"Remember Me": persistent with Max-Age set far in future; the session record gets a long TTL in Redis.
Session stores: Redis is the most popular because it is fast, supports TTL natively, and allows horizontal scaling (any server can read the same session data).
Your team is building a REST API consumed by a single-page application that will be deployed across multiple server instances behind a load balancer. A colleague suggests using server-side sessions stored in each server's local memory. What is the primary problem with this approach, and what is the most common solution?
4. Trade-offs and Common Mistakes
Trade-offs
| Cookie/Session | Token (JWT) | |
|---|---|---|
| State location | Server | Client |
| Logout | Easy — delete server record | Hard — token valid until expiry |
| Scale | Requires shared store | Stateless, no shared store |
| Revocation | Instant | Requires token blacklist or short expiry |
Common Mistakes
Storing IDs in localStorage: JavaScript-accessible storage is trivially read by . Use HttpOnly cookies instead.
Missing SameSite: browser defaults and edge cases have changed over time, so relying on them is brittle. Set SameSite=Lax or Strict explicitly unless you truly need cross-site cookies.
Storing sensitive data in the body: The travels over the wire. Only store a random, opaque ID. Keep sensitive data server-side.
Not rotating IDs on login: If an attacker captures a pre-login session ID (session fixation), they hijack the session. Regenerate the session ID on every privilege change.
Infinite session TTL: Sessions should expire. Use Max-Age and enforce server-side expiry.
Using the same cookie across environments: dev cookies leaking into prod or vice versa.
Server sessions trade shared storage for easy revocation
Server sessions use a shared store when servers scale out and revoke access at once. Stateless JWTs need no shared store but stay valid until expiry.
A session keeps user state on the server, while a JWT carries state on the client.
Text version
- A session keeps user state on the server, while a JWT carries state on the client.
- Deleting the session record logs the user out, while a stateless JWT remains valid until expiry.
- Scaled-out servers read sessions from one shared store, while stateless JWTs need no shared session store.
- Deleting a session revokes access at once, while revoking a JWT early needs a denylist that the server checks on each request.
5. Interview Angle
How to explain it
" is stateless. Cookies let the browser carry a small piece of state — usually just a ID — on every request. The server maps that ID to a record held in a store like Redis. The key security controls are
HttpOnlyto prevent theft,Secureto enforce , andSameSiteto prevent ."
Common interview questions
Q: What is the difference between a and a session? A: A is client-side storage (browser). A session is server-side storage. They work together: the server creates a session, gives the browser a session-ID cookie, and looks up the session on each request.
Q: How do you prevent from stealing session tokens?
A: Set HttpOnly on the session cookie so JavaScript cannot read it.
Q: How do you prevent ?
A: SameSite=Strict/Lax stops the browser from sending the cookie on cross-site requests (Lax still sends it on top-level GET navigations). It is defense in depth, so also use a CSRF token.
Q: What happens when you scale horizontally with server sessions? A: All servers must read from the same session store. Move sessions to Redis or a database rather than in-process memory.
Q: Cookie vs. for auth? A: Sessions are easier to revoke and don't expose user data client-side. JWTs are stateless and simpler to scale. For most apps with revocation requirements (logout, ban), sessions win. For microservices or APIs without login/logout, JWTs make sense.
Three cookie flags guard the session ID
HttpOnly hides the session cookie from JavaScript, Secure keeps it on HTTPS, and SameSite=Lax keeps it off cross-site POST requests.
Without HttpOnly, any script on the page can read the session cookie, so an XSS bug can steal it.
Text version
- Without HttpOnly, any script on the page can read the session cookie, so an XSS bug can steal it.
- Secure tells the browser to send the cookie only over HTTPS, never over plain HTTP.
- SameSite set to Lax limits cross-site sends, so a forged POST from another site arrives without the session cookie.
6. Practical Examples
Express.js server session with Redis
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient();
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24, // 24 hours
},
}));
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
req.session.regenerate(() => { // prevent session fixation
req.session.userId = user.id;
res.json({ ok: true });
});
});Django session (built-in)
# settings.py
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default' # points to Redis
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
# view
def login_view(request):
user = authenticate(request, username=request.POST['username'], password=request.POST['password'])
if user is None:
return JsonResponse({'ok': False}, status=401)
request.session.cycle_key() # regenerate session ID
request.session['user_id'] = user.id
return JsonResponse({'ok': True})Reading a cookie in a browser request (for debugging)
GET /api/me HTTP/1.1
Host: api.example.com
Cookie: session_id=abc123xyzThe ID is opaque — it reveals nothing about the user. All sensitive data stays on the server.
Regenerate the session ID on login
An attacker may know a pre-login session ID. Regenerating the ID on login prevents that ID from becoming the authenticated session ID.
An attacker plants a session ID before login, so they already know the ID the browser sends.
Text version
- An attacker plants a session ID before login, so they already know the ID the browser sends.
- If login keeps that old ID, the attacker can use it to hijack the authenticated session.
- After checking credentials, Express regenerates the session ID before storing the user ID, while Django calls cycle key.
- The browser receives a new session ID, so the attacker cannot use the old ID to access the authenticated session.
In the Express.js login handler shown below, why is req.session.regenerate() called immediately after a user successfully authenticates?
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
req.session.regenerate(() => {
req.session.userId = user.id;
res.json({ ok: true });
});
});