Sessions & Cookies

HTTP is stateless by design — cookies and sessions are the mechanisms that add memory back, each with a different security surface and a different set of production pitfalls

← Back to Index

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:

MechanismWhere state livesWhat travels on the wireTypical use
CookieBrowser (client)The actual data value, on every requestPreferences, non-sensitive UI state, authentication tokens
SessionServerOnly a short opaque session IDAuthenticated 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
AttributeWhat it doesProduction default?
HttpOnlyJavaScript cannot read this cookie at all — document.cookie does not include itAlways, for any auth-related cookie
SecureBrowser only sends this cookie over HTTPS, never plain HTTPAlways in production
SameSite=StrictCookie is never sent with cross-site requests — not even navigating from another domainAuth cookies where you don't need cross-site links to work
SameSite=LaxCookie 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; SecureAlways sent cross-site — required for embeds, iframes, OAuth redirectsOnly when you genuinely need cross-site cookie sending; Secure is then mandatory
Max-AgeLifetime in seconds from now; takes precedence over Expires when both are presentPrefer Max-Age over Expires — it's relative, not an absolute date
PathURL path scope — cookie is only sent to matching paths/ for session cookies; narrower for per-feature cookies
The SameSite triad — and the silent breaking change that hit everyone

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
}
Store only the user ID in the session, never the whole entity

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     │
              └─────────────┘
JDBC sessions are a valid alternative when you already have a relational DB

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 it

An 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.
AspectServer-Side SessionJWT
State locationServer (Redis, JDBC, in-memory)Inside the token — client holds everything
Horizontal scalingRequires shared storeStateless — any instance can verify
Instant revocationDelete the session record — doneRequires a blocklist or short expiry + refresh token
Token size~30 bytes (session ID)~200-500 bytes (signed payload)
Stale dataAlways fresh — data is on the serverPayload is a snapshot; role changes don't reflect until the token expires
Browser supportAutomatic via cookiesManual — client must store and send the header
Best fitTraditional web apps, admin panels, apps needing instant revocationStateless REST APIs, microservices, mobile apps, cross-domain auth
JWT revocation is the problem tutorials don't mention

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();
}
Never store the remember-me token directly — store a hash

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 explicit SameSite on 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 HttpOnly is one XSS exploit away from being stolen
  • Don't rely on SameSite browser 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

🎓 Junior level

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.

🔥 Senior level

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.