JWT Token Storage : localStorage ? SessionStorage? or Cookies ?

·

16 min read

What is best above all ?

Here i have tried to find out best solution while implementing JWT. I find it unusual that as our Financial companies implement these JWT based security during EOL -EOS Scan or during Vulnerability Scan. CSRF attack always shown.

Here i have try to find best practice for all these .

In a full-stack application where React is the frontend, JWT (JSON Web Token) can be stored in different places depending on your needs and security considerations. Here are the main options:

  1. LocalStorage:

    • Pros: Easy to use, persists across sessions (even after page refresh).

    • Cons: Vulnerable to XSS (Cross-Site Scripting) attacks since JavaScript can access localStorage. If an attacker can inject malicious scripts into your app, they could steal the token.

  2. SessionStorage:

    • Pros: Like localStorage, but the data is cleared when the browser tab is closed.

    • Cons: Still vulnerable to XSS attacks, and data doesn't persist across sessions or tabs.

  3. HttpOnly Cookies (Recommended for most secure apps):

    • Pros: The token is stored in a cookie that is sent automatically with every HTTP request, and the HttpOnly flag ensures the cookie cannot be accessed via JavaScript, providing protection against XSS attacks.

    • Cons: Requires proper configuration of the server to send cookies with the right SameSite attribute, and may require handling CSRF (Cross-Site Request Forgery) tokens if you use cookies for authentication.

Recommendation:

For better security, storing the JWT token in an HttpOnly cookie is the most secure approach, as it is not accessible via JavaScript, which mitigates the risk of XSS. Additionally, you should use Secure cookies to ensure they are only sent over HTTPS.

Steps for using HttpOnly cookies:

  1. Set the JWT in the backend when the user logs in, as an HttpOnly cookie.

  2. On the frontend, the JWT is automatically included in requests to the backend since it's stored in the cookie.

  3. Backend can read and verify the JWT from the cookie for each request.

    BUT again question comes ? Is it still safe ? NO

    Great! Let’s dive into more detail on some of these strategies. We can explore how to implement each approach for storing and handling JWTs in your full-stack application.

    This method involves storing the JWT in a JavaScript variable in memory, meaning it will be lost once the page is refreshed.

    How to Implement:

    • Store the JWT in your application’s state (like React’s useState, Redux, or Context API).

    • Add the JWT to the HTTP request headers manually when making API calls.

    // Using Context API to store JWT
    import React, { createContext, useContext, useState } from 'react';

    const AuthContext = createContext();

    export const useAuth = () => useContext(AuthContext);

    export const AuthProvider = ({ children }) => {
      const [token, setToken] = useState(null);

      const login = (jwtToken) => setToken(jwtToken);
      const logout = () => setToken(null);

      return (
        <AuthContext.Provider value={{ token, login, logout }}>
          {children}
        </AuthContext.Provider>
      );
    };

    // Using the token in an API request
    import { useAuth } from './AuthContext';

    const fetchData = async () => {
      const { token } = useAuth();
      const response = await fetch('/api/secure-data', {
        headers: {
          Authorization: `Bearer ${token}`
        }
      });
      const data = await response.json();
      console.log(data);
    };
  • Pros: Secure from XSS, easy to implement, no need to manage cookies or local storage.

  • Cons: Token is lost on page refresh, meaning users must re-authenticate or use a backend endpoint to re-fetch the token.

HttpOnly cookies are more secure because they cannot be accessed by JavaScript. They also automatically handle sending the token with each request.

How to Implement:

  1. Set JWT in HttpOnly Cookie on Server: When the user logs in, the backend sets the JWT in an HttpOnly cookie.
    // Node.js (Express) example
    app.post('/login', (req, res) => {
      const jwtToken = generateJWT(user); // Create JWT
      res.cookie('auth_token', jwtToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production', // Only send over HTTPS
        sameSite: 'Strict' // Protect against CSRF
      });
      res.json({ message: 'Logged in successfully' });
    });
  1. Frontend - Make API Requests: Since the browser will automatically send the token stored in the HttpOnly cookie, you don’t need to manually attach it to each request. However, you can still check if the user is logged in via the cookie.
    // Frontend - React
    const fetchData = async () => {
      const response = await fetch('/api/secure-data', {
        method: 'GET',
        credentials: 'include', // Send the cookie along with the request
      });
      const data = await response.json();
      console.log(data);
    };
  • Pros:

    • Secure: HttpOnly cookie prevents access by JavaScript, mitigating XSS risks.

    • Automatic: The browser handles sending the cookie with every request, making it easy to implement.

    • Persistence: Cookies persist across page reloads.

  • Cons:

    • CSRF: You need to protect against CSRF (Cross-Site Request Forgery) attacks by using the SameSite cookie attribute, and potentially using anti-CSRF tokens if needed.

    • Requires HTTPS: The Secure flag needs HTTPS to work.

3. Refresh Tokens (Combination of Access Tokens and Refresh Tokens)

This is the best solution when you want both security and persistence. The access token is short-lived, while the refresh token is long-lived and stored in a secure HttpOnly cookie.

How to Implement:

  1. Login Process (Server):

    • When the user logs in, the server generates both an access token and a refresh token.

    • The refresh token is stored securely in an HttpOnly cookie, and the access token is returned to the frontend.

    // Node.js (Express) example
    app.post('/login', (req, res) => {
      const accessToken = generateAccessToken(user);
      const refreshToken = generateRefreshToken(user);

      res.cookie('refresh_token', refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'Strict'
      });

      res.json({ accessToken });
    });
  1. Frontend - Use Access Token: The frontend uses the access token to authenticate requests to the API.
    // Frontend - Store access token in memory
    const [accessToken, setAccessToken] = useState(null);

    const fetchData = async () => {
      const response = await fetch('/api/secure-data', {
        headers: {
          Authorization: `Bearer ${accessToken}`
        }
      });
      const data = await response.json();
      console.log(data);
    };
  1. Refresh Access Token: If the access token expires, you use the refresh token to obtain a new access token from the server.
    // Node.js (Express) example - Refresh access token
    app.post('/refresh-token', (req, res) => {
      const refreshToken = req.cookies.refresh_token;
      if (!refreshToken) {
        return res.status(401).json({ message: 'No refresh token' });
      }

      // Verify refresh token
      const user = verifyRefreshToken(refreshToken);
      if (!user) {
        return res.status(401).json({ message: 'Invalid refresh token' });
      }

      // Generate a new access token
      const newAccessToken = generateAccessToken(user);
      res.json({ accessToken: newAccessToken });
    });
  1. Frontend - Handle Token Expiry: After the access token expires, make a request to the /refresh-token endpoint to get a new access token.
    const fetchData = async () => {
      const response = await fetch('/api/secure-data', {
        headers: {
          Authorization: `Bearer ${accessToken}`
        }
      });

      if (response.status === 401) {  // If unauthorized, refresh the token
        const refreshResponse = await fetch('/refresh-token', { credentials: 'include' });
        const data = await refreshResponse.json();
        setAccessToken(data.accessToken);
        return fetchData(); // Retry the original request with the new token
      }

      const data = await response.json();
      console.log(data);
    };
  • Pros:

    • Secure: The access token is short-lived, reducing the impact of a potential compromise.

    • Persistent: The refresh token can be used to get a new access token without requiring re-authentication.

  • Cons:

    • Requires more backend logic for handling token expiration and refresh.

    • You still need to secure against CSRF if you're using cookies.


4. OAuth/OpenID Connect (Third-party Authentication)

Using a service like Auth0, Firebase Auth, or Okta for authentication abstracts the JWT handling and provides built-in security features, such as token expiration management, refresh token handling, and secure storage.

How to Implement:

  • Use the SDK provided by the authentication service (Auth0, Firebase, Okta) to handle login, logout, and token management.

  • The service typically manages JWT tokens in a secure way, often using cookies or secure storage.

  • It integrates easily with your React app via their SDKs, and you don’t have to worry about token handling as much.

    // Example with Auth0 SDK
    import { useAuth0 } from '@auth0/auth0-react';

    const MyComponent = () => {
      const { loginWithRedirect, logout, user, getAccessTokenSilently } = useAuth0();

      const fetchData = async () => {
        const token = await getAccessTokenSilently();
        const response = await fetch('/api/secure-data', {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        });
        const data = await response.json();
        console.log(data);
      };

      return (
        <div>
          <button onClick={loginWithRedirect}>Log in</button>
          <button onClick={logout}>Log out</button>
          {user && <div>Welcome, {user.name}</div>}
        </div>
      );
    };
  • Pros:

    • Minimal management: The service handles all token management for you, including expiration, refresh tokens, and storage.

    • Secure by default: These services implement best practices in security.

  • Cons:

    • Dependency on a third-party service.

    • Costs: Some services may have usage costs as your app grows.


Final Recommendations:

  • If security is a priority, HttpOnly cookies combined with refresh tokens is a great approach.

  • If you want complete control over authentication without dealing with complex token storage, using OAuth or Auth0 can save you time.

  • If you're aiming for simplicity with session management, in-memory storage might be enough, but it will lose persistence on page refresh.

    Sure! Let's create a full-stack example using Spring Boot for the backend (Java) and React for the frontend, while implementing best practices for protecting user login data against XSS and CSRF attacks.

    Approach Overview

    We'll implement the following:

    1. Spring Boot (Backend):

      • JWT-based authentication (access token + refresh token).

      • Secure the refresh token using HttpOnly cookies.

      • Protect against CSRF using a CSRF token and SameSite cookie attribute.

      • Set up CORS to allow frontend to interact with the backend securely.

    2. React (Frontend):

      • Handle login and authentication using JWT (access token).

      • Automatically refresh JWT using the refresh token (sent via HttpOnly cookies).

      • Prevent XSS and CSRF vulnerabilities.

Full Stack Example Code

Backend (Spring Boot)

  1. Dependencies (add these to pom.xml):
        <dependencies>
            <!-- Spring Boot -->
            <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>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>0.11.2</version>
            </dependency>
            <!-- JWT Authentication and Spring Security -->
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-oauth2-jose</artifactId>
                <version>5.8.2</version>
            </dependency>
            <!-- Spring Boot CORS support -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-cors</artifactId>
            </dependency>
        </dependencies>
  1. Security Configuration (JWT and CSRF)

SecurityConfig.java — Configure Spring Security to handle JWT, CSRF, CORS, and HttpOnly cookies.

        import org.springframework.context.annotation.Bean;
        import org.springframework.context.annotation.Configuration;
        import org.springframework.http.HttpMethod;
        import org.springframework.security.config.annotation.web.builders.HttpSecurity;
        import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
        import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
        import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

        @Configuration
        @EnableWebSecurity
        public class SecurityConfig extends WebSecurityConfigurerAdapter {

            @Override
            protected void configure(HttpSecurity http) throws Exception {
                http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).and()
                    .cors().and()
                    .authorizeRequests()
                    .antMatchers("/login", "/refresh-token", "/register").permitAll() // Allow public routes
                    .anyRequest().authenticated() // Protect other routes
                    .and()
                    .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
            }

            @Bean
            public CorsFilter corsFilter() {
                return new CorsFilter();
            }
        }
  1. JWT Utilities

JwtUtils.java — Utility methods for creating and validating JWTs.

        import io.jsonwebtoken.Jwts;
        import io.jsonwebtoken.SignatureAlgorithm;
        import java.util.Date;

        public class JwtUtils {

            private static final String SECRET_KEY = "secret"; // Use a more secure key in production
            private static final long EXPIRATION_TIME = 86400000; // 24 hours

            public static String generateJwtToken(String username) {
                return Jwts.builder()
                    .setSubject(username)
                    .setIssuedAt(new Date())
                    .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                    .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                    .compact();
            }

            public static String extractUsername(String token) {
                return Jwts.parser()
                    .setSigningKey(SECRET_KEY)
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
            }

            public static boolean validateJwtToken(String token) {
                try {
                    Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
                    return true;
                } catch (Exception e) {
                    return false;
                }
            }
        }
  1. JWT Authentication Filter

JwtAuthenticationFilter.java — Intercepts incoming requests and checks for valid JWT tokens in the Authorization header.

        import org.springframework.security.core.Authentication;
        import org.springframework.security.core.context.SecurityContextHolder;
        import org.springframework.web.filter.OncePerRequestFilter;
        import javax.servlet.Filter;
        import javax.servlet.FilterChain;
        import javax.servlet.ServletException;
        import javax.servlet.http.HttpServletRequest;
        import javax.servlet.http.HttpServletResponse;
        import java.io.IOException;

        public class JwtAuthenticationFilter extends OncePerRequestFilter {

            @Override
            protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                    throws ServletException, IOException {

                String jwt = getJwtFromRequest(request);
                if (jwt != null && JwtUtils.validateJwtToken(jwt)) {
                    String username = JwtUtils.extractUsername(jwt);
                    Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>());
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }

                filterChain.doFilter(request, response);
            }

            private String getJwtFromRequest(HttpServletRequest request) {
                String header = request.getHeader("Authorization");
                if (header != null && header.startsWith("Bearer ")) {
                    return header.substring(7);
                }
                return null;
            }
        }
  1. Login and Token Generation Endpoint

AuthController.java — Handles user login and provides access and refresh tokens.

        import org.springframework.web.bind.annotation.*;

        @RestController
        @RequestMapping("/api/auth")
        public class AuthController {

            @PostMapping("/login")
            public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
                // Validate username and password (replace with real validation)
                if ("user".equals(loginRequest.getUsername()) && "password".equals(loginRequest.getPassword())) {
                    String accessToken = JwtUtils.generateJwtToken(loginRequest.getUsername());
                    String refreshToken = JwtUtils.generateJwtToken(loginRequest.getUsername()); // Refresh token logic can be implemented differently

                    // Set refresh token in HttpOnly cookie
                    Cookie cookie = new Cookie("refresh_token", refreshToken);
                    cookie.setHttpOnly(true);
                    cookie.setSecure(true); // Ensure it's sent over HTTPS
                    cookie.setPath("/");
                    cookie.setMaxAge(60 * 60 * 24); // 1 day
                    response.addCookie(cookie);

                    return ResponseEntity.ok(new AuthResponse(accessToken));
                }
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
            }

            @PostMapping("/refresh-token")
            public ResponseEntity<?> refreshToken(@CookieValue("refresh_token") String refreshToken) {
                // Validate and create new access token
                String username = JwtUtils.extractUsername(refreshToken);
                String newAccessToken = JwtUtils.generateJwtToken(username);
                return ResponseEntity.ok(new AuthResponse(newAccessToken));
            }
        }
  1. CSRF Protection

CookieCsrfTokenRepository.java — Helps store the CSRF token in a cookie for protection against CSRF attacks.

        import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

This automatically stores the CSRF token in a cookie, which can be sent back in requests from the frontend.


Frontend (React)

  1. Dependencies (install using npm or yarn):
        npm install axios react-router-dom
  1. React Context for Authentication

AuthContext.js — Manage user authentication state globally.

        import React, { createContext, useState, useContext } from 'react';

        const AuthContext = createContext();

        export const useAuth = () => useContext(AuthContext);

        export const AuthProvider = ({ children }) => {
            const [token, setToken] = useState(localStorage.getItem('access_token'));

            const login = (token) => {
                setToken(token);
                localStorage.setItem('access_token', token);
            };

            const logout = () => {
                setToken(null);
                localStorage.removeItem('access_token');
            };

            return (
                <AuthContext.Provider value={{ token, login, logout }}>
                    {children}
                </AuthContext.Provider>
            );
        };
  1. Login Component

Login.js — Handle user login and JWT token storage.

        import React, { useState } from 'react';
        import axios from 'axios';
        import { useAuth } from './AuthContext';

        const Login = () => {
            const { login } = useAuth();
            const [username, setUsername] = useState('');
            const [password, setPassword] = useState('');

            const handleLogin = async (e) => {
                e.preventDefault();
                try {
                    const response = await axios.post('http://localhost:8080/api/auth/login', { username, password }, {
                        withCredentials: true,
                    });
                    login(response.data.accessToken);
                } catch (error) {
                    console.error('Error logging in', error);
                }
            };

            return (
                <form onSubmit={handleLogin}>
                    <input
                        type="text"
                        placeholder="Username"
                        value={username}
                        onChange={(e) => setUsername(e.target.value)}
                    />
                    <input
                        type="password"
                        placeholder="Password"
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                    />
                    <button type="submit">Login</button>
                </form>
            );
        };
  1. Protecting Routes and Automatic Token Refresh

ProtectedRoute.js — Component to protect authenticated routes.

        import React, { useEffect, useState } from 'react';
        import { useAuth } from './AuthContext';
        import axios from 'axios';
        import { Redirect } from 'react-router-dom';

        const ProtectedRoute = ({ children }) => {
            const { token, logout } = useAuth();
            const [isAuthenticated, setIsAuthenticated] = useState(true);

            useEffect(() => {
                if (!token) {
                    setIsAuthenticated(false);
                } else {
                    axios.get('http://localhost:8080/protected', { headers: { Authorization: `Bearer ${token}` } })
                        .then(response => setIsAuthenticated(true))
                        .catch(() => {
                            setIsAuthenticated(false);
                            logout();
                        });
                }
            }, [token, logout]);

            if (!isAuthenticated) {
                return <Redirect to="/login" />;
            }

            return children;
        };
  1. App.js

Main app component using the AuthContext and protected routes.

        import React from 'react';
        import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
        import { AuthProvider } from './AuthContext';
        import Login from './Login';
        import ProtectedRoute from './ProtectedRoute';

        const App = () => {
            return (
                <AuthProvider>
                    <Router>
                        <Switch>
                            <Route path="/login" component={Login} />
                            <ProtectedRoute>
                                <Route path="/protected" component={() => <div>Protected Page</div>} />
                            </ProtectedRoute>
                        </Switch>
                    </Router>
                </AuthProvider>
            );
        };

        export default App;

Important Security Notes:

  • XSS Prevention: React automatically escapes input to prevent XSS by default.

  • CSRF: The backend uses CookieCsrfTokenRepository to store CSRF tokens. The frontend sends CSRF tokens automatically with requests (handled by Spring Security).

  • JWT in HttpOnly Cookies: The refresh token is stored securely in HttpOnly cookies to prevent access by JavaScript.

  • HTTPS: Always use HTTPS in production to secure token transmission.


Summary

This full-stack example demonstrates how to integrate JWT authentication with Spring Boot and React, while ensuring protection against XSS and CSRF attacks:

  • HttpOnly cookies are used to securely store JWT refresh tokens.

  • CSRF protection is configured in Spring Boot.

  • React handles JWT in memory for the access token and uses cookies for the refresh token.

CSRF (Cross-Site Request Forgery) is a type of attack where a malicious actor tricks a user into executing unwanted actions on a web application where the user is authenticated. The attacker exploits the trust that a web application has in the user's browser.

How it works:

  1. Victim Authentication: The victim is logged into a website (e.g., online banking, email, or social media), and their browser has a valid session cookie or token for authentication.

  2. Malicious Request: The attacker crafts a malicious request (like submitting a form or making an API call) that performs an action the victim doesn't intend to perform (such as changing the victim's account password, transferring funds, or deleting data).

  3. Request Execution: The victim unknowingly clicks on a link or visits a website that contains this malicious request. The victim's browser sends the request to the target website, including any authentication tokens (like cookies) automatically, because they are still active in the browser for that site.

  4. Action Performed: Since the request looks legitimate (because it comes with valid credentials like cookies), the web server processes it and executes the unwanted action as if it were initiated by the user.

Example Scenario:

  1. You are logged into your bank's website, and you have a session cookie that authenticates you to perform banking actions.

  2. An attacker sends you an email with a link that, when clicked, makes a request to the bank's site to transfer money from your account to the attacker's account. The link might look like this:

     http://mybank.com/transfer?amount=1000&to=attackerAccount
    
  3. When you click the link (while still logged into your bank), your browser sends the request to the bank's server, including your session cookie for authentication. Since you're authenticated, the bank processes the transfer, thinking you requested it.

CSRF Attack Prevention:

To protect against CSRF attacks, several mechanisms can be used:

  1. CSRF Tokens:

    • A CSRF token is a unique, unpredictable value generated by the server and included in requests. When submitting forms or making API requests, the client must send this token as part of the request (usually in the request body or headers).

    • The server checks if the token in the request matches the one it generated. If they don't match, the request is rejected.

  2. SameSite Cookies:

    • Setting the SameSite attribute for cookies can help prevent CSRF by ensuring that cookies are only sent with requests originating from the same domain (not from third-party sites).

    • SameSite=Strict: Cookies are sent only with requests from the same site.

    • SameSite=Lax: Cookies are sent with requests from the same site, but also with top-level navigations (e.g., links).

    • SameSite=None: Cookies are sent with requests from any site, but only if the cookie is Secure (i.e., sent over HTTPS).

  3. Referer and Origin Header Checks:

    • The server can check the Referer or Origin headers in incoming requests to ensure they originate from the correct domain.
  4. Double Submit Cookies:

    • The server sends a CSRF token in both a cookie and as a part of the request (in the body or header). The client then sends the token from both locations in the request, and the server verifies they match.
  5. Authentication Flow Design:

    • Use authentication mechanisms that are resistant to CSRF, such as token-based authentication (e.g., JWT) where the token is sent in the request header, not relying on cookies.

Why CSRF is Dangerous:

  • Automated Execution: Attackers can trick users into executing requests without their knowledge or consent.

  • Web Application Vulnerabilities: Applications that do not check or validate the authenticity of requests can be exploited for malicious actions.

  • Sensitive Actions: CSRF can be used to execute sensitive operations, such as financial transactions, password changes, or sending unauthorized messages.

Protection in Modern Applications:

  • In Single Page Applications (SPAs) like those built with React or Angular, JWTs (JSON Web Tokens) are often stored in local storage or in-memory instead of cookies, so CSRF attacks are less of a concern.

  • For traditional server-rendered applications that rely on cookies for authentication, implementing CSRF tokens and SameSite cookies is critical for security.