Learnitweb

JWT Usage in a React Application

When you use JWT in a React frontend, the typical workflow is:

  1. A user logs in by providing credentials (username/password).
  2. The backend authenticates the credentials and generates a JWT.
  3. The frontend receives the JWT and uses it to authenticate subsequent API requests.
  4. The backend verifies the JWT for every request, checking the signature, claims, and expiration.
  5. 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 injects document.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 or Lax) 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:

  1. User logs in.
  2. Backend returns the JWT as a cookie: Set-Cookie: token=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
  3. Browser stores it automatically, and subsequent requests automatically include it.
  4. 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:
    Use SameSite=Strict or SameSite=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 or Lax 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)

MethodSecurity Against XSSSecurity Against CSRFPersistenceNotes
localStorage❌ No✅ Yes✅ Persists after browser closeEasy to implement, vulnerable to XSS; not recommended for sensitive tokens
sessionStorage❌ No✅ Yes❌ Cleared on browser closeSlightly safer than localStorage, still XSS vulnerable
HttpOnly cookie✅ Yes❌ Needs CSRF protection✅ Persists based on cookie expiryMost secure, protects from XSS; requires CSRF mitigation
In-memory (React state/Redux)✅ Yes✅ Yes❌ Lost on page refreshVolatile, but protects from XSS and CSRF; useful for short-lived tokens

✅ Recommended Strategy for React Applications

  1. Use HttpOnly, Secure, SameSite cookies to store access tokens and refresh tokens.
  2. Implement CSRF protection using tokens or SameSite cookies.
  3. Keep access tokens short-lived and rotate them using refresh tokens.
  4. Avoid storing tokens in localStorage or sessionStorage.
  5. Ensure HTTPS is used for all communication.
  6. Audit your frontend for XSS vulnerabilities, sanitize user inputs, and use libraries like DOMPurify for HTML content.
  7. 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:

  1. Create a filter that intercepts all requests.
  2. Extract the JWT from the cookie.
  3. Validate the JWT.
  4. 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());