Enhancing API Security: Implementing JWT with Refresh and Access Tokens

Introduction

In the AplicacionJoyeria project, ensuring robust and seamless user authentication was paramount. Traditional session-based authentication can be stateful and challenging to scale, especially for distributed systems and mobile clients. Our goal was to adopt a more modern, stateless approach while maintaining high security and a smooth user experience. This led us to implement JSON Web Tokens (JWT) with a dual-token strategy: short-lived access tokens and longer-lived refresh tokens.

Prerequisites

  • Basic understanding of RESTful API concepts.
  • Familiarity with Java and the Spring Framework.
  • Knowledge of token-based authentication principles.

The Dual-Token Advantage: Addressing JWT's Statelessness

JWTs offer a powerful way to implement stateless authentication, where the server doesn't need to store session information. However, relying solely on a single, long-lived JWT poses a security risk: if compromised, it grants prolonged unauthorized access. Conversely, a very short-lived token can degrade user experience due to frequent re-authentication. The dual-token strategy resolves this by using:

  1. Access Token: Short-lived, used for authenticating requests to protected resources.
  2. Refresh Token: Long-lived, used only to obtain new access tokens when the current one expires, without requiring the user to re-enter credentials.

Step 1: User Authentication and Initial Token Issuance

When a user successfully logs in, the server generates both an access token and a refresh token. The access token typically has a short expiry (e.g., 5-15 minutes), while the refresh token has a much longer one (e.g., several days or weeks). The refresh token is often stored securely (e.g., in an HTTP-only cookie on the client side, or a secure database on the server). Here's a conceptual snippet for the login endpoint:

@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
    // Authenticate user credentials
    // ...

    String accessToken = jwtTokenProvider.generateAccessToken(authentication);
    String refreshToken = jwtTokenProvider.generateRefreshToken(authentication);

    // Store refresh token securely (e.g., database)
    refreshTokenService.storeRefreshToken(refreshToken, loginRequest.getUsername());

    return ResponseEntity.ok(new JwtResponse(accessToken, refreshToken));
}

This authenticateUser method validates credentials, generates both tokens, stores the refresh token on the server side (often linked to the user), and returns them to the client.

Step 2: Accessing Protected Resources with the Access Token

For subsequent requests to protected API endpoints, the client includes the access token in the Authorization header. Our API's security filter intercepts these requests, validates the access token, and permits access if it's valid and unexpired.

// JWT Authentication Filter (conceptual)
public class JwtAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String accessToken = header.substring(7);
            if (jwtTokenProvider.validateToken(accessToken)) {
                // Extract user details from token and set in SecurityContext
                // ...
            } else {
                // Token invalid or expired
            }
        }
        filterChain.doFilter(request, response);
    }
}

This filter ensures that only requests with a valid access token can reach our secured endpoints.

Step 3: Refreshing the Access Token

When the access token expires, the client can use the refresh token to request a new access token without requiring the user to log in again. This usually involves a dedicated API endpoint.

@PostMapping("/refresh-token")
public ResponseEntity<?> refreshAccessToken(@RequestBody RefreshTokenRequest request) {
    String requestRefreshToken = request.getRefreshToken();

    return refreshTokenService.findByToken(requestRefreshToken)
        .map(refreshToken -> refreshTokenService.verifyExpiration(refreshToken))
        .map(RefreshToken::getUser)
        .map(user -> {
            String newAccessToken = jwtTokenProvider.generateAccessTokenFromUsername(user.getUsername());
            return ResponseEntity.ok(new JwtResponse(newAccessToken, requestRefreshToken));
        })
        .orElseThrow(() -> new TokenRefreshException(requestRefreshToken, "Refresh token is not in database!"));
}

This refreshAccessToken endpoint validates the refresh token against a stored record and, if valid, issues a new access token. If the refresh token itself has expired or is invalid, the user must log in again.

Step 4: Client-Side Token Management (React Example)

On the client side, typically a React application, both tokens are stored securely. The access token is sent with every API request, and a mechanism is in place to detect an expired access token and trigger a refresh request.

// Frontend API client (conceptual)
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  headers: { 'Content-Type': 'application/json' }
});

apiClient.interceptors.request.use(config => {
  const accessToken = localStorage.getItem('accessToken');
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

apiClient.interceptors.response.use(response => response, async error => {
  const originalRequest = error.config;
  if (error.response.status === 401 && !originalRequest._retry) {
    originalRequest._retry = true;
    try {
      const refreshToken = localStorage.getItem('refreshToken');
      const response = await apiClient.post('/refresh-token', { refreshToken });
      const { accessToken: newAccessToken } = response.data;
      localStorage.setItem('accessToken', newAccessToken);
      apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
      return apiClient(originalRequest);
    } catch (refreshError) {
      // Handle refresh token expiry/invalidity: force logout
      console.error('Refresh token expired or invalid', refreshError);
      // Redirect to login
      return Promise.reject(refreshError);
    }
  }
  return Promise.reject(error);
});

This JavaScript interceptor demonstrates how a client can automatically try to refresh an expired access token, enhancing the user experience.

Results

By implementing JWT with refresh and access tokens, AplicacionJoyeria now benefits from:

  • Enhanced Security: Short-lived access tokens limit the window of opportunity for attackers.
  • Improved User Experience: Users remain logged in for extended periods without frequent re-authentication.
  • Scalability: Stateless tokens simplify horizontal scaling of backend services.

This robust authentication mechanism forms a secure foundation for our application.

Next Steps

Consider advanced security measures such as refresh token revocation upon logout or password change, and implementing proper logging and monitoring for token-related activities. Also, explore using secure HTTP-only cookies for refresh tokens to mitigate XSS attacks.


Generated with Gitvlg.com

Enhancing API Security: Implementing JWT with Refresh and Access Tokens
J

Johandev

Author

Share: