I came across a tweet some time back reminding developers to use HttpOnly cookies for tokens instead of localStorage, shout out to Captain
It immediately hit me, many of us (especially frontend developers) still store tokens in localStorage because it’s simple.
But simple doesn’t always mean safe.
In this article, I’ll explain why storing tokens in localStorage exposes your app to cross-site scripting (XSS) attacks and how you can fix that by using HttpOnly cookies with Spring Boot and React.
What Are XSS attacks
XSS (Cross-Site Scripting) attacks occur when an attacker injects malicious JavaScript into a website, causing that script to run in another user’s browser. This can allow attackers to steal cookies, hijack sessions, manipulate page content, redirect users, or perform actions on their behalf. XSS happens when applications display untrusted input without proper sanitization or escaping, making it a common security risk in web applications.
The Problem with LocalStorage
When you store your authentication token in localStorage, any malicious JavaScript injected into your page can access it.
For example, imagine a script like this running on your page:
// Imagine a hacker injected this script:
console.log(localStorage.getItem("token"));
If your site has any XSS vulnerability maybe from unsafe user input or a poorly sanitized field this code could expose every user’s token to an attacker.
That token can then be used to impersonate the user and make authenticated requests to your backend.
So while localStorage seems convenient, it gives attackers a simple way to steal sensitive information if they can run scripts on your page.
The HttpOnly Cookie Fix
Instead of manually storing tokens in the browser’s localStorage, we can store them in an HttpOnly cookie.
Here’s why that’s safer:
HttpOnly means JavaScript can’t access the cookie (so XSS can’t read it).
Cookies are automatically sent with every request to your backend (no need to attach tokens manually).
You can mark them as Secure so they only travel over HTTPS.
This approach keeps your authentication token safely tucked away invisible to the browser’s JavaScript environment.
Setting It Up (Spring Boot + React Example)
- Backend Setup (Spring Boot)
Step 1: Add Dependencies (pom.xml)
This step sets up the required Spring Boot dependencies for building a REST API secured with JWT. spring-boot-starter-web enables REST endpoints, spring-boot-starter-security provides authentication and authorization support, and Lombok helps reduce boilerplate code like getters, setters, and constructors
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
Step 2: JWT Authentication Filter
Here we create a custom JWT authentication filter that runs on every request. It reads the JWT from the HTTP-only cookie, validates it, extracts the username, and if valid, sets the authentication context so protected endpoints recognize the user as logged in.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
//Your JWT Utility class for generating, extracting, validation jwts
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain) throws
ServletException, IOException {
String token = extractTokenFromCookie(request);
// Extract JWT from cookie
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("authToken".equals(cookie.getName())) {
token = cookie.getValue();
break;
}
}
}
if (token != null) {
try {
// Validate and extract username
String username = jwtUtil.extractUsername(token);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication()
== null) {
// Validate token
if (jwtUtil.validateToken(token, username)) {
// Create authentication object
UsernamePasswordAuthenticationToken authentication
= new UsernamePasswordAuthenticationToken(
username,
null,
Collections.emptyList() // Add
authorities if needed
);
authentication.setDetails(
new
WebAuthenticationDetailsSource()
.buildDetails(request)
);
// Set authentication in security context
SecurityContextHolder.getContext()
.setAuthentication(authentication);
}
}
} catch (Exception e) {
// Log the error but don't block the request
logger.error("JWT authentication failed", e);
}
}
filterChain.doFilter(request, response);
}
//Utility method to extract token from cookie
private String extractTokenFromCookie(HttpServletRequest request) {
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("authToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
Step 3: Security Configuration (Enable CORS)
This step configures Spring Security to enable CORS with credentials, disable CSRF for stateless APIs, specify which routes are public, enforce JWT-based stateless sessions, and register the JWT filter so all protected requests must include a valid token.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfig()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers( "/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-ui.html").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfig() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:5173"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT",
"DELETE", "OPTIONS"));
config.setAllowCredentials(true);
config.setAllowedHeaders(List.of("*")); // Required when sending
cookies with CORS
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new
UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
Step 4: Authentication Controller
The authentication controller handles login, logout, and checking authentication status. On login, it generates a JWT and stores it in a secure HTTP-only cookie. Logout clears the cookie, and the /authenticated endpoint confirms whether the current request is associated with an authenticated user.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "http://localhost:5173", allowCredentials = "true")
public class AuthController {
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request, HttpServletResponse response) {
if ("user".equals(request.getUsername()) && "password".equals(request.getPassword())) {
// Your JWT token generation logic
String token = jwtUtil.generateToken(request.getUsername());
// Create httpOnly cookie
ResponseCookie cookie = ResponseCookie.from("authToken", token)
.httpOnly(true)
.secure(true) // Set true in production with HTTPS
.path("/")
.maxAge(24 * 60 * 60) // 24 hours
.sameSite("Lax")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(
AuthResponse.builder()
.message("Login Successful")
.build()
);
}
return ResponseEntity.status(401)
.body(AuthResponse.builder()
.message("Invalid credentials")
.build());
}
@PostMapping("/logout")
public ResponseEntity<AuthResponse> logout(HttpServletResponse response) {
ResponseCookie cookie = ResponseCookie.from("authToken", "")
.httpOnly(true)
.secure(true)
.path("/")
.sameSite("Lax")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(AuthResponse.builder()
.message("Logout Successful")
.build());
}
@GetMapping("/authenticated")
public ResponseEntity<AuthResponse> getCurrentUser() {
String username = SecurityContextHolder.getContext()
.getAuthentication()
.getName();
if ("anonymousUser".equals(username)) {
return ResponseEntity.status(401)
.body(AuthResponse.builder()
.message("Not authenticated")
.build());
}
return ResponseEntity.ok(AuthResponse.builder()
.message("Authenticated" + " " + username)
.build());
}
}
Step 5: Using Cookie in Protected Endpoints
Protected endpoints can now read and validate the JWT directly from cookies. This step demonstrates two approaches—manually accessing cookies from the request, or using Spring’s @CookieValue annotation—to authenticate users and return secured data.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class DataController {
private final JwtUtil jwtUtil;
// Method 1: Get cookie from request
@GetMapping("/profile")
public ResponseEntity<?> getProfile(HttpServletRequest request) {
String token = getUserFromCookie(request);
if(token != null) {
try{
// Extract username from JWT token
String username = jwtUtil.extractUsername(token);
// Validate token
if (jwtUtil.validateToken(token, username)) {
return ResponseEntity.ok(Map.of("username", username));
}
}catch (Exception e) {
return ResponseEntity.status(401).body("Invalid token");
}
}
return ResponseEntity.status(401).body("Unauthorized");
}
// Method 2: Using @CookieValue annotation
@GetMapping("/dashboard")
public ResponseEntity<?> getDashboard(
@CookieValue(name = "authToken", required = false) String authToken) {
if(authToken != null) {
try{
// Extract username from JWT token
String username = jwtUtil.extractUsername(authToken);
// Validate token
if (jwtUtil.validateToken(authToken, username)) {
return ResponseEntity.ok(Map.of("data", "Dashboard data for " + username));
}
} catch (Exception e) {
return ResponseEntity.status(401).body("Invalid token");
}
}
return ResponseEntity.status(401).body("Unauthorized");
}
private String getUserFromCookie(HttpServletRequest request) {
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("authToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
That’s it! Your backend now issues and validates JWTs securely using HTTP-only cookies, allowing the client to stay authenticated without exposing the token to JavaScript.
Frontend Setup (React + Vite)
Step 1: Install Axios
npm install axios
Step 2: Configure Axios (src/api.js)
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:8080/api',
withCredentials: true, // This sends cookies automatically!
});
export default api;
Step 3: Login Component
import { useState } from 'react';
import api from './api';
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e) => {
e.preventDefault();
setLoading(true);
try {
await api.post('/auth/login', { username, password });
alert('Login successful!');
// Redirect to dashboard
} catch (error) {
alert('Login failed');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleLogin}>
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Login</button>
</form>
);
}
Step 4: Making Authenticated Requests
import { useEffect, useState } from 'react';
import api from './api';
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
// Cookie is automatically sent with this request!
api.get('/profile')
.then(res => setData(res.data))
.catch(err => console.log('Not authenticated'));
}, []);
const handleLogout = async () => {
try {
await api.post("/auth/logout");
// Redirect to login
} catch (err) {
console.error("Logout failed", err);
}
};
return (
<div>
<h2>Dashboard</h2>
<p>Welcome, {data?.username}</p>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
Testing It
To verify your setup:
Log in to your app.
Open DevTools → Application → Cookies.
You’ll see your JWT stored as a cookie.
Confirm that HttpOnly and Secure are both ticked, this shows that your token is truly secured
Now open your browser console and run:
document.cookie
Notice that your token does not appear because it’s HttpOnly.
That’s exactly what we want. Even if someone injects a script into your site, they won’t be able to read that cookie.
Bonus Tip: CSRF Protection
Since cookies are automatically sent with every request, make sure to also enable CSRF protection in your backend (Spring Security makes this simple).
This ensures attackers can’t trick a logged-in user into making unwanted actions.
Conclusion
By switching from localStorage to HttpOnly cookies, you protect authentication tokens from being accessed by injected JavaScript, greatly reducing exposure to XSS attacks.
In short:
Tokens in localStorage → vulnerable to XSS
Tokens in HttpOnly cookies → protected and automatically sent with requests
If this helped you, share it with a developer who still stores tokens in localStorage .
Top comments (0)