When you use JWT in a React frontend, the typical workflow is:
- A user logs in by providing credentials (username/password).
- The backend authenticates the credentials and generates a JWT.
- The frontend receives the JWT and uses it to authenticate subsequent API requests.
- The backend verifies the JWT for every request, checking the signature, claims, and expiration.
- The frontend optionally handles token expiration by refreshing the token using a refresh token.
While this seems straightforward, security pitfalls often occur in how the token is stored and transmitted. An insecure implementation can expose sensitive information, allow attackers to impersonate users, or open your app to XSS/CSRF attacks.
Best Practices for Secure JWT Handling in React
1. Avoid Local Storage and Session Storage
While it’s common to see tutorials storing JWTs in localStorage
or sessionStorage
, this is not secure:
- Both are fully accessible from JavaScript. If an attacker injects malicious JavaScript into your app (XSS attack), they can read the JWT and impersonate the user.
localStorage
persists even after the browser is closed, so stolen tokens can be reused indefinitely.sessionStorage
is cleared on browser close, but it is still exposed to JavaScript, so an XSS attack can still steal it.- Example of a security breach:
If an attacker injectsdocument.getItem("token")
, they can retrieve the JWT and gain full access to user accounts.
Takeaway: Only use localStorage or sessionStorage if you have strong XSS mitigations in place, but it is not recommended for sensitive authentication tokens.
2. Use HttpOnly Cookies (Most Recommended)
The safest approach is to store the JWT in an HttpOnly cookie:
- HttpOnly cookies are not accessible from JavaScript, preventing XSS from stealing tokens.
- When marked Secure, they are only sent over HTTPS connections, protecting them from network eavesdropping.
- Cookies can be sent with the
SameSite
attribute (Strict
orLax
) to reduce the risk of CSRF attacks. - The browser automatically includes the cookie with each request to your backend, so you don’t need to manually attach the token in the
Authorization
header.
Example flow:
- User logs in.
- Backend returns the JWT as a cookie:
Set-Cookie: token=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
- Browser stores it automatically, and subsequent requests automatically include it.
- Backend validates the JWT for every API request.
Advantages:
- Protects against XSS because JavaScript cannot access the cookie.
- Handles automatic sending with requests, simplifying frontend code.
- Secure and scalable for long-lived applications when combined with refresh tokens.
Disadvantages/considerations:
- CSRF protection must still be considered because cookies are automatically sent in requests.
- Requires proper configuration on the backend to handle SameSite, domain, and path settings correctly.
3. Implement CSRF Protection if Using Cookies
Because cookies are automatically sent by the browser with each request, a malicious site could trigger requests on behalf of a logged-in user if CSRF is not mitigated.
Options for CSRF protection:
- SameSite Cookies:
UseSameSite=Strict
orSameSite=Lax
to prevent cross-site requests from sending your JWT. - CSRF Tokens:
Backend generates a unique token per session and requires it to be sent as a custom header for state-changing requests (POST, PUT, DELETE). The token cannot be accessed by a third-party site.
Example header for CSRF protection:
X-CSRF-Token: <token-from-backend>
Takeaway: Combining HttpOnly cookies with CSRF tokens or SameSite cookies provides strong protection against both XSS and CSRF.
4. Set Secure Flags on Cookies
When using cookies for JWTs, always set the following flags:
- HttpOnly: Prevents JavaScript access.
- Secure: Ensures the cookie is sent only over HTTPS.
- SameSite: Reduces cross-site attacks (
Strict
orLax
depending on your use case). - Path: Restricts which paths on your domain the cookie is sent to.
- Max-Age/Expires: Controls token expiration in the browser.
This combination ensures that the token is confidential, integrity-protected, and transmitted safely.
5. Use Short-Lived Access Tokens with Refresh Tokens
JWTs should be short-lived (e.g., 5–15 minutes):
- Limits damage if the token is stolen.
- Encourages secure session management.
Refresh tokens are used to obtain new access tokens when the old ones expire:
- Store refresh tokens also in HttpOnly, Secure cookies.
- Backend validates the refresh token and issues a new access token.
- Frontend can automatically refresh the access token without user intervention, providing a seamless experience.
This pattern reduces the exposure of long-lived JWTs in the client.
6. Avoid Storing JWTs in Redux, React State, or Memory Long-Term
- Storing in memory (React state or Redux) is better than localStorage because it is not persisted to disk and is cleared on page refresh.
- It protects against attacks where local storage is directly accessed, but the token is lost on refresh.
- Ideal for short-lived storage between login and an API request.
- Never log JWTs to console, files, or DevTools as this exposes them to developers or attackers.
7. Always Use HTTPS
- JWTs are bearer tokens: whoever has the token can act as the user.
- HTTPS ensures that tokens cannot be intercepted over the network (MITM attack).
- Never allow your React app to run on HTTP in production when using JWTs.
Summary Table (Expanded)
Method | Security Against XSS | Security Against CSRF | Persistence | Notes |
---|---|---|---|---|
localStorage | ❌ No | ✅ Yes | ✅ Persists after browser close | Easy to implement, vulnerable to XSS; not recommended for sensitive tokens |
sessionStorage | ❌ No | ✅ Yes | ❌ Cleared on browser close | Slightly safer than localStorage, still XSS vulnerable |
HttpOnly cookie | ✅ Yes | ❌ Needs CSRF protection | ✅ Persists based on cookie expiry | Most secure, protects from XSS; requires CSRF mitigation |
In-memory (React state/Redux) | ✅ Yes | ✅ Yes | ❌ Lost on page refresh | Volatile, but protects from XSS and CSRF; useful for short-lived tokens |
✅ Recommended Strategy for React Applications
- Use HttpOnly, Secure, SameSite cookies to store access tokens and refresh tokens.
- Implement CSRF protection using tokens or SameSite cookies.
- Keep access tokens short-lived and rotate them using refresh tokens.
- Avoid storing tokens in localStorage or sessionStorage.
- Ensure HTTPS is used for all communication.
- Audit your frontend for XSS vulnerabilities, sanitize user inputs, and use libraries like DOMPurify for HTML content.
- For additional security, consider token scopes and backend authorization checks to limit token misuse.
Spring Boot Reads JWT from Cookie
Spring Boot can read cookies in controllers or filters:
Option 1: Reading in Controller (manual approach)
@GetMapping("/profile") public ResponseEntity<UserProfile> getProfile(@CookieValue("access_token") String token) { // token contains JWT from HttpOnly cookie String username = jwtService.getUsernameFromToken(token); UserProfile profile = userService.getProfile(username); return ResponseEntity.ok(profile); }
@CookieValue
automatically reads the cookie from the request.- You can then use your JWT service to validate the token, extract claims, and authorize the user.
Option 2: Reading in a Filter (Recommended)
For global authentication, it’s better to use a Spring Security filter:
- Create a filter that intercepts all requests.
- Extract the JWT from the cookie.
- Validate the JWT.
- Set the authenticated user in the SecurityContext.
Example:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtService jwtService; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // Read JWT from cookie Cookie[] cookies = request.getCookies(); String token = null; if (cookies != null) { for (Cookie cookie : cookies) { if ("access_token".equals(cookie.getName())) { token = cookie.getValue(); } } } if (token != null && jwtService.validateToken(token)) { String username = jwtService.getUsernameFromToken(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // Set authentication in SecurityContext SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } }
- This filter runs before your controllers, so every secured request has the user authenticated automatically.
jwtService.validateToken(token)
should check:- Signature
- Expiration
- Optional claims like roles
3. Spring Security Configuration
Register the filter in your security config:
@EnableWebSecurity public class SecurityConfig { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() // Use proper CSRF handling if cookies are used .authorizeHttpRequests(auth -> auth .antMatchers("/login", "/register").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }
- The
jwtAuthenticationFilter
runs before Spring Security’s default authentication filter. - Any request with a valid JWT in the cookie is automatically authenticated.
4. Sending Secure Responses Back
- You can also rotate JWTs or issue refresh tokens by sending updated HttpOnly cookies from the backend.
- Example of refreshing an access token:
ResponseCookie refreshCookie = ResponseCookie.from("access_token", newJwt) .httpOnly(true) .secure(true) .path("/") .maxAge(Duration.ofMinutes(15)) .sameSite("Strict") .build(); response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());