The Problem: HTTP Has No Memory — and the Two Ways to Add It
HTTP is stateless: every request is independent. The server receives a request, processes it, sends a response, and immediately forgets the connection existed. This is a deliberate design decision that makes HTTP scalable and simple — but it means that without additional mechanisms, a user would have to authenticate on every single request.
// Without any state mechanism — server forgets instantly
Request 1: POST /login {email: "ada@example.com", password: "..."}
Response 1: 200 OK "Welcome Ada"
Request 2: GET /dashboard
Response 2: 401 Unauthorized "Who are you?" // server has no memory
Two mechanisms exist to solve this, and they solve it differently:
| Mechanism | Where state lives | What travels on the wire | Typical use |
|---|---|---|---|
| Cookie | Browser (client) | The actual data value, on every request | Preferences, non-sensitive UI state, authentication tokens |
| Session | Server | Only a short opaque session ID | Authenticated user data, shopping carts, multi-step flows |
// With session-based authentication — the server remembers via a shared key
Request 1: POST /login {email: "ada@example.com", password: "..."}
Response 1: 200 OK
Set-Cookie: JSESSIONID=f3a9b12c... // server creates session, sends back the ID
Request 2: GET /dashboard
Cookie: JSESSIONID=f3a9b12c... // browser sends it automatically
Response 2: 200 OK {user: "Ada Lovelace", ...} // server looks up session by ID
Cookies: Anatomy and Security Flags
A cookie is a name/value pair set by the server via
Set-Cookie and sent by the browser on every subsequent
request to the matching domain and path. The attributes that follow
the value control its security surface — getting them wrong is where
most real cookie vulnerabilities come from.
Set-Cookie: auth_token=eyJhbGciOi...;
Path=/;
Max-Age=3600;
Secure;
HttpOnly;
SameSite=Strict
| Attribute | What it does | Production default? |
|---|---|---|
HttpOnly | JavaScript cannot read this cookie at all — document.cookie does not include it | Always, for any auth-related cookie |
Secure | Browser only sends this cookie over HTTPS, never plain HTTP | Always in production |
SameSite=Strict | Cookie is never sent with cross-site requests — not even navigating from another domain | Auth cookies where you don't need cross-site links to work |
SameSite=Lax | Cookie is sent with top-level navigation (clicking a link) but not embedded cross-site requests (forms, iframes, XHR) | The safer default for most apps — more usable than Strict |
SameSite=None; Secure | Always sent cross-site — required for embeds, iframes, OAuth redirects | Only when you genuinely need cross-site cookie sending; Secure is then mandatory |
Max-Age | Lifetime in seconds from now; takes precedence over Expires when both are present | Prefer Max-Age over Expires — it's relative, not an absolute date |
Path | URL path scope — cookie is only sent to matching paths | / for session cookies; narrower for per-feature cookies |
Until 2020, browsers defaulted to treating cookies with no
SameSite attribute as SameSite=None.
Chrome 80 (February 2020) changed the default to
SameSite=Lax. Any application that relied on
cross-site cookie sending without explicitly setting
SameSite=None; Secure silently broke — embedded
iframes stopped receiving auth cookies, OAuth flows broke on
redirect, and third-party integrations stopped authenticating.
Always set SameSite explicitly. Never rely on the
browser default.
Setting and Reading Cookies in Spring
// Setting — always prefer ResponseCookie over the legacy Cookie API
// The legacy Cookie class predates SameSite support and can't set it.
@PostMapping("/login")
public ResponseEntity<Void> login(@Valid @RequestBody LoginRequest request) {
String sessionId = authService.authenticate(request); // throws on bad credentials
ResponseCookie cookie = ResponseCookie.from("session_id", sessionId)
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.path("/")
.maxAge(Duration.ofHours(8))
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
// Reading — @CookieValue is cleaner than scanning request.getCookies()
@GetMapping("/profile")
public UserResponse getProfile(
@CookieValue(name = "session_id", required = false) String sessionId) {
if (sessionId == null) {
throw new UnauthorizedException("No session");
}
return sessionService.getUser(sessionId);
}
// Deleting — set Max-Age to 0 with the same path as when it was created
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
ResponseCookie expired = ResponseCookie.from("session_id", "")
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.path("/")
.maxAge(0) // instructs browser to delete immediately
.build();
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, expired.toString())
.build();
}
Server-Side Sessions: HttpSession and the Scaling Problem
A session stores data on the server keyed by a cryptographically random session ID. The browser holds only that ID — the actual data (user identity, cart contents, permissions) never leaves the server. This is the key security property: even if someone intercepts or steals the session ID cookie, they get a meaningless opaque string unless they can also reach the server's session store.
// Spring MVC — HttpSession is injected directly
@PostMapping("/login")
public ResponseEntity<Void> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
User user = authService.authenticate(request); // throws UnauthorizedException on failure
// Session fixation prevention: invalidate any pre-login session,
// then create a fresh one with a new ID. Without this, an attacker
// who knows a victim's pre-login session ID can hijack it post-login.
HttpSession old = httpRequest.getSession(false);
if (old != null) old.invalidate();
HttpSession session = httpRequest.getSession(true);
session.setAttribute("userId", user.getId()); // store the ID, not the whole entity
session.setAttribute("roles", user.getRoles());
session.setMaxInactiveInterval(30 * 60); // 30 min inactivity timeout
return ResponseEntity.ok().build();
}
@GetMapping("/dashboard")
public DashboardResponse getDashboard(HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
if (userId == null) {
throw new UnauthorizedException("No active session");
}
return dashboardService.forUser(userId);
}
@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void logout(HttpSession session) {
session.invalidate(); // destroys the server-side data; the cookie becomes orphaned
}
Storing a full User JPA entity in the session means
the session serializes a snapshot of the user at login time.
If that user's role changes, their email is updated, or they're
deactivated, the session still holds the old snapshot — and
unless the session is explicitly invalidated, they keep operating
with stale data. Store only the userId and
roles, and fetch a fresh entity from the database
when you need the full user.
The Horizontal Scaling Problem — and Its Fix
// DEFAULT: sessions live in each instance's JVM heap
┌─────────────────┐ ┌─────────────────┐
│ Instance A │ │ Instance B │
│ session abc123 │ │ (no sessions) │
└────────┬────────┘ └────────┬────────┘
│ Load Balancer │
└─────────────────────────────────┘
// Request 1 → Instance A: login succeeds, session abc123 created
// Request 2 → Instance B: no session abc123 → user appears logged out
// This is why sticky sessions (pinning a user to one instance) exist —
// but they're a workaround, not a solution. They make deployments and
// failovers fragile.
// FIX: Spring Session externalises session storage transparently
// The application code doesn't change — only the dependency and one property line.
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
# application.properties
spring.session.store-type=redis
spring.session.timeout=30m
spring.data.redis.host=${REDIS_HOST}
spring.data.redis.port=6379
// Now all instances share the same session store.
// Any request to any instance finds the same session.
┌─────────────────┐ ┌─────────────────┐
│ Instance A │ │ Instance B │
└────────┬────────┘ └────────┬────────┘
│ │
└───────────┬───────────┘
│
┌──────┴──────┐
│ Redis │
│ session │
│ abc123 │
└─────────────┘
If Redis isn't in your stack and you already have a database,
spring-session-jdbc + spring.session.store-type=jdbc
works the same way. It's slower than Redis for session lookups
at high volume, but eliminates the operational overhead of a
separate Redis cluster for teams that don't yet need that scale.
Spring Boot Session Properties
# application.properties — one place to configure all session cookie behaviour
server.servlet.session.timeout=30m
server.servlet.session.cookie.name=SESSION
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax
The Three Security Attacks You Must Defend Against
1. Session Fixation
An attacker who can set a victim's session ID before they log in (via a URL parameter, a known network, or a shared computer) can hijack the session the moment the victim authenticates. The fix is always the same: invalidate the pre-authentication session and create a new one with a fresh ID on successful login.
// Spring Security handles this automatically — explicit config shown for clarity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.sessionManagement(session -> session
.sessionFixation().newSession() // new ID, preserves attributes (the safe default)
// .sessionFixation().migrateSession() — same, older name, equivalent
// .sessionFixation().changeSessionId() — cheapest: changes ID only, no copy needed
);
return http.build();
}
}
2. XSS (Cross-Site Scripting) and Cookie Theft
If an attacker can inject JavaScript that executes in your page, and
your session cookie is readable via document.cookie, the
attack is trivial: one line of JavaScript exfiltrates the session ID
to an attacker-controlled server. HttpOnly removes the
cookie from JavaScript's reach entirely — the script cannot read a
value that doesn't appear in document.cookie. This is
not optional for any authentication cookie.
HttpOnly doesn't prevent XSS — it only removes one attack vector from itAn attacker with XSS execution can still make authenticated
requests on behalf of the user (their browser will send the
HttpOnly cookie automatically on any request it
initiates). HttpOnly prevents the session token
itself from being extracted and used elsewhere. Both protections
matter: HttpOnly to protect the token value,
SameSite to restrict which requests the browser
attaches it to.
3. CSRF (Cross-Site Request Forgery)
A malicious page on another domain loads an image or submits a form
targeting your API. The victim's browser — being helpful — includes
the session cookie automatically. Without a defense, the server has
no way to tell whether the request was initiated by your application
or by a malicious third-party page. Two defenses exist, and in 2026
SameSite=Lax alone handles the common case without
additional complexity.
// Defense 1: SameSite=Lax (the modern default, sufficient for most apps)
// Lax blocks embedded cross-site requests (forms, XHR, fetch) but allows
// top-level GET navigations (clicking a link into your site).
// It does NOT block a cross-site POST form — that's why Strict exists.
// Defense 2: CSRF token — the classic defense, still needed for SameSite=None
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Spring Security enables CSRF protection by default for stateful apps.
// It generates a token that must be sent in a header or form field —
// a cross-site page cannot read this token, so it can't forge the request.
.csrf(Customizer.withDefaults())
// For pure REST APIs that use stateless JWT, not sessions, CSRF is
// typically disabled — there's no cookie for a cross-site page to exploit:
// .csrf(csrf -> csrf.disable())
;
return http.build();
}
}
Sessions vs JWT — the Real Trade-offs
Sessions and JWTs (JSON Web Tokens) are not interchangeable. They solve the same problem — "how does the server know who this request is from?" — with fundamentally different properties, and choosing the wrong one for a given context creates real operational problems.
// Session-based flow
Browser ──── Cookie: JSESSIONID=abc123 ────▶ Server
└─▶ Redis: look up "abc123"
└─▶ Returns: {userId: 42, roles: ["ADMIN"]}
// One network hop to Redis per request under the session path.
// Revocation: delete the key from Redis → session is dead immediately.
// JWT-based flow
Client ──── Authorization: Bearer eyJhbGci... ────▶ Server
└─▶ Verifies cryptographic signature (local)
└─▶ Reads: {userId: 42, roles: ["ADMIN"], exp: ...}
// Zero network hops for verification — just CPU (signature check).
// Revocation: the token is valid until its exp claim, period.
// If you need to revoke before expiry, you need a blocklist — which means
// a network hop to check it, collapsing the "no database lookup" advantage.
| Aspect | Server-Side Session | JWT |
|---|---|---|
| State location | Server (Redis, JDBC, in-memory) | Inside the token — client holds everything |
| Horizontal scaling | Requires shared store | Stateless — any instance can verify |
| Instant revocation | Delete the session record — done | Requires a blocklist or short expiry + refresh token |
| Token size | ~30 bytes (session ID) | ~200-500 bytes (signed payload) |
| Stale data | Always fresh — data is on the server | Payload is a snapshot; role changes don't reflect until the token expires |
| Browser support | Automatic via cookies | Manual — client must store and send the header |
| Best fit | Traditional web apps, admin panels, apps needing instant revocation | Stateless REST APIs, microservices, mobile apps, cross-domain auth |
Every JWT tutorial shows token generation and verification. Almost none shows revocation — because JWTs are designed to be self-verifying, there's no built-in way to invalidate one before it expires. "Just use short expiry + refresh tokens" is the standard answer, but it trades one problem (revocation) for another (refresh token storage, which is often a session by another name). If your application needs to instantly invalidate a token on logout, role change, or account suspension, you need either a blocklist (re-introducing a shared store) or server-side sessions. The choice isn't "sessions vs JWT" — it's "which trade-offs fit my requirements."
The "Remember Me" Pattern
Short session timeouts (30 minutes) are correct for security but frustrating for users on their personal devices. The "remember me" pattern provides a long-lived persistent token separate from the session — so the session can expire while the user stays logged in on their own device without permanently weakening security.
@PostMapping("/login")
public ResponseEntity<Void> login(
@Valid @RequestBody LoginRequest request,
@RequestParam(defaultValue = "false") boolean rememberMe,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
User user = authService.authenticate(request);
HttpSession old = httpRequest.getSession(false);
if (old != null) old.invalidate(); // session fixation prevention
httpRequest.getSession(true).setAttribute("userId", user.getId());
if (rememberMe) {
String token = rememberMeService.createToken(user); // stored hashed in DB
ResponseCookie persistent = ResponseCookie.from("remember_me", token)
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.path("/")
.maxAge(Duration.ofDays(30))
.build();
httpResponse.addHeader(HttpHeaders.SET_COOKIE, persistent.toString());
}
return ResponseEntity.ok().build();
}
If the remember_me_tokens table is leaked in a data
breach, storing raw tokens means every token becomes immediately
usable for account takeover. Hash the token with
BCrypt or SHA-256 before persisting it,
and compare on lookup. Spring Security's built-in
PersistentTokenBasedRememberMeServices does this
correctly out of the box — use it rather than rolling your own.
Best Practices and Common Pitfalls
✅ Do
- Set
HttpOnly,Secure, and an explicitSameSiteon every authentication-related cookie — never leave any of the three unset - Invalidate the pre-login session and create a new one on successful authentication — session fixation is exploitable and the fix is two lines
- Store only the
userId(and minimal role info) in the session — never a full JPA entity or a serialized object that can become stale - Use Spring Session (Redis or JDBC) the moment you have more than one instance — sticky sessions are a deployment fragility, not a scaling strategy
- Choose sessions over JWT when you need instant revocation — "logout means logout" without a blocklist requires sessions
❌ Don't
- Don't store sensitive data in cookies that are readable by JavaScript — any auth cookie without
HttpOnlyis one XSS exploit away from being stolen - Don't rely on
SameSitebrowser defaults — they changed in 2020 and differ between browser versions; always set it explicitly - Don't store passwords or plaintext credentials anywhere in a session or cookie — ever, under any circumstances
- Don't skip invalidating the session on logout — an invalidated but not-server-side-deleted session can be replayed by anyone who captured it
- Don't assume JWT is always better than sessions because it's "stateless" — if you add a blocklist for revocation, you now have a stateful JWT solution that's more complex than sessions
Interview Questions
Q: What is the difference between a cookie and a session?
A cookie is data stored in the browser and sent with every request
to the matching domain. A session is data stored on the server,
identified by a short opaque ID that the browser holds in a cookie.
The key difference: with a cookie, the actual data travels over the
network; with a session, only a meaningless ID travels — the data
stays on the server.
Q: What does HttpOnly on a cookie do, and why does it matter?
It prevents JavaScript from reading the cookie via
document.cookie. This means a successful XSS attack
that executes JavaScript in your page cannot read and exfiltrate the
session token. Without HttpOnly, one injected script
line is enough to steal every authenticated user's session.
Q: What is a session fixation attack?
An attacker causes the victim to use a session ID the attacker
already knows — by setting it in a URL, a shared machine, or a
network the attacker controls. When the victim logs in, the session
becomes authenticated and the attacker, who already knows the ID,
can use it. The fix: always invalidate the current session on login
and create a new one with a fresh cryptographically random ID.
Q: You deploy your Spring Boot app to three instances behind a load balancer. Users keep getting "logged out" randomly. What's happening and what are the correct ways to fix it?
The sessions are stored in each instance's JVM heap by default.
When the load balancer routes a request to a different instance than
the one that created the session, that instance has no record of it
and the user appears unauthenticated. The correct fix is to
externalise session storage with Spring Session — spring-session-data-redis
for high-volume production, spring-session-jdbc if Redis
isn't available. All instances then read and write the same session
store, so any instance can serve any request. The wrong fix — sticky
sessions — pins a user to one instance; it "works" in the steady
state but breaks on restarts, deployments, and instance failures,
and defeats the point of having multiple instances for resilience.
Q: Your team proposes replacing all session-based authentication with JWTs to "eliminate the Redis dependency." What are the actual trade-offs and what would you verify before agreeing?
The argument is valid if the application doesn't need instant
revocation and the JWT payload doesn't grow large. What to verify:
first, does the application need to immediately revoke access on
logout, role change, or account suspension? If yes, a JWT blocklist
re-introduces a shared store — you've eliminated Redis but added a
database table that must be checked on every request, which is slower
than Redis was. Second, what's in the JWT payload? A token containing
roles, permissions, and user metadata sent on every request can
exceed 1KB — multiplied by request volume, this is measurable
overhead. Third, what's the token lifetime strategy? A 24-hour JWT
with no refresh means a compromised token is valid for 24 hours
regardless of logout. Short JWTs (15 minutes) plus refresh tokens
are more secure but require storing refresh tokens somewhere — often
a Redis or database entry, which is operationally similar to what
was eliminated. The conclusion: JWTs are genuinely better for
microservice-to-microservice auth and mobile apps where sharing a
cookie store is impractical. For a traditional web app where instant
revocation matters, sessions with shared Redis are simpler to operate
and reason about correctly.
Q: What does SameSite=Lax actually protect against, and what does it not protect against?
SameSite=Lax blocks cookies from being sent on
embedded cross-site subresource requests — XHR, fetch, iframes, and
image loads from a different origin — which covers the classic CSRF
attack scenario where a malicious page loads a resource on your
server. It does not block a cross-site top-level POST form
submission — a crafted form on a malicious page submitted via
JavaScript can still carry the cookie under Lax on some browser
versions depending on whether it's treated as a top-level navigation.
For state-changing endpoints, the belt-and-suspenders posture is
SameSite=Lax (or Strict) plus explicit
CSRF tokens for any form submission or mutating API call — not
SameSite alone as a complete CSRF defense.