Scalable JWT Token Revokation in Spring Boot

With stateless JWT Tokens for security, short TTLs (1 min) can be used. These tokens are then refreshed during their time to live. If the server does not get to know when a user has logged out, a token of a logged-out user could continue to be refreshed. One solution for this problem will be shown here that keeps a lot of the horizontal scalability.

Architecture

The architecture shows the microservices each with its own DB. The revoked tokens and the users need a single source of truth. The database needs to be highly available with multi-master or hot standby or another feature of the database. The revoked token database needs only two tables: one for the revoked tokens that gets called every 90 seconds by the microservices that cache the content of the revoked tokens table, and if the user logs out, and one for the users for the login. The microservices update the revoked tokens table after each logout with a defined time to live of the row and the logins are rate limited. That architecture reduces the load on the revoked tokens database to make it scale to larger deployments. The single source of truth for the revoked tokens is needed because each user request can be processed on any microservice and the revoked tokens need to be checked there. The user table is needed to enable the microservices to log in the users. That spreads out the load for the security checks over the microservices. The JWT token is checked in memory on the microservice and adds only a little CPU and no IO load.

Implementation

The implementation of the revoked tokens can be found in the MovieManager project.

Login

To support the revoked tokens, the login checks the number of currently revoked tokens of the user and slows down the login speed to limit the amount of revoked tokens a user can generate. That is done in the UserDetailsMgmt service:

private UserDto loginHelp(Optional<User> entityOpt, String passwd) {
   UserDto user = new UserDto();
   Optional<Role> myRole = entityOpt.stream()
      .flatMap(myUser -> Arrays.stream(Role.values())
	.filter(role1 -> Role.USERS.equals(role1))
           .filter(role1 -> 
              role1.name().equals(myUser.getRoles()))).findAny();
   if (myRole.isPresent() && entityOpt.get().isEnabled()
    && this.passwordEncoder.matches(passwd, entityOpt.get().getPassword())) {
	Callable<String> callableTask = () -> this.jwtTokenService
           .createToken(entityOpt.get()
              .getUsername(), Arrays.asList(myRole.get()), Optional.empty());
        try {
	   String jwtToken = executorService
              .schedule(callableTask, 3, TimeUnit.SECONDS).get();
 	   user = this.jwtTokenService
             .userNameLogouts(entityOpt.get().getUsername()) > 2 ? 
                user : this.userMapper.convert(entityOpt.get(), 
                   jwtToken, 0L);
	} catch (InterruptedException | ExecutionException e) {
	   LOG.error("Login failed.", e);
	}
   }
   return user;
}

First, the Optional role of User entity is filtered out. Then it is checked if the User entity is present and has the Users role, is enabled, and the password matches.

Then, a callable is created to create the JWT token for the user. The token has the Username and a UUID to identify each token on logout. The callable is executed with a 3-second delay on a different thread pool to limit the number of logouts a user can do between updates of the revoked token cache.

Next, it is checked if more than 2 revoked tokens are cached for the user. If true, the login is denied.

These 2 checks make sure that the amount of revoked tokens a user can generate is limited and limits the load on the login.

For horizontal scalability, this table has to be moved to the RevokedToken database.

Logout

The logo is implemented in the UserDetailsMgmt service:

public Boolean logout(String bearerStr) {
   if (!this.jwtTokenService.validateToken(
      this.jwtTokenService.resolveToken(bearerStr).orElse(""))) {
	throw new AuthenticationException("Invalid token.");
   }
   String username = this.jwtTokenService.getUsername(
      this.jwtTokenService
        .resolveToken(bearerStr).orElseThrow(() -> 
           new AuthenticationException("Invalid bearer string.")));
   String uuid = this.jwtTokenService
      .getUuid(this.jwtTokenService.resolveToken(bearerStr)
	 .orElseThrow(() -> 
             new AuthenticationException("Invalid bearer string.")));
   this.userRepository.findByUsername(username).orElseThrow(() -> 
      new ResourceNotFoundException("Username not found: " + username));
   long revokedTokensForUuid = this.revokedTokenRepository.findAll().stream()
	.filter(myRevokedToken -> myRevokedToken.getUuid().equals(uuid)
	   && myRevokedToken.getName().equalsIgnoreCase(username)).count();
   if (revokedTokensForUuid == 0) {
      this.revokedTokenRepository.save(new RevokedToken(username, uuid,  
         LocalDateTime.now()));
   } else {
      LOG.warn("Duplicate logout for user {}", username);
   }
   return Boolean.TRUE;
}

First, it is checked if the JWT token is valid. Next, the username and the UUID are read out of the JWT token. Then, the Users table is checked for the user with the username of the token. The revokedTokens are checked for entries with the same UUID and UserID. If an entry is found, a warning is logged about a duplicate logout try. If it is the first log out of the JWT token, a new RevokedToken entity with username, UUID, and the current time is created in the revoked token table.

For horizontal scalability, this table has to be moved to the RevokedToken database.

Revoked Tokens Cache Updates

The revoked tokens cache is updated with the CronJobs component:

@Scheduled(fixedRate = 90000)
public void updateLoggedOutUsers() {
   LOG.info("Update logged out users.");
   this.userService.updateLoggedOutUsers();
}

Every 90 seconds the revoked tokes are read out of the table.

The update is handled in the UserDetailsMgmt service:

public void updateLoggedOutUsers() {
   final List<RevokedToken> revokedTokens =
      new ArrayList<RevokedToken>(this.revokedTokenRepository.findAll());
   this.jwtTokenService.updateLoggedOutUsers(
      revokedTokens.stream().filter(myRevokedToken -> 
         myRevokedToken.getLastLogout() == null || 
         !myRevokedToken.getLastLogout()
         .isBefore(LocalDateTime.now()
         .minusSeconds(LOGOUT_TIMEOUT))).toList());
   this.revokedTokenRepository.deleteAll(
     revokedTokens.stream().filter(myRevokedToken -> 
        myRevokedToken.getLastLogout() != null && myRevokedToken
         .getLastLogout().isBefore(LocalDateTime.now()
            .minusSeconds(LOGOUT_TIMEOUT))).toList());		    
}

First, all revoked tokens are read from the table. Then, the entries that are older than the LOGOUT_TIMEOUT (185 sec) are removed. The others are cached in the JwtTokenService.

The JwtTokenService manages the revoked token cache:

public record UserNameUuid(String userName, String uuid) {}
private final List<UserNameUuid> loggedOutUsers = 
   new CopyOnWriteArrayList<>();

public void updateLoggedOutUsers(List<RevokedToken> revokedTokens) {
   this.loggedOutUsers.clear();
   this.loggedOutUsers.addAll(revokedTokens.stream()
     .map(myRevokedToken -> new UserNameUuid(myRevokedToken.getName(), 
	myRevokedToken.getUuid())).toList());
}

The UserNameUuid record has the values ​​to identify the tokens. The loggedOutUsers list has the UserNameUuids of the logged-out users/revoked tokens. The CopyOnWriteArrayList is thread-safe.

The updateLoggedOutUsers gets the current list of revoked tokens and clears and then updates the loggedOutUsers list. The list is used for token validation.

JWT Token Validation

The JWT tokens have a username and hash that are checked for validation. Now the JWT tokens are also checked against the loggedOutUsers list to check the logouts. This is done in the JwtTokenFilter:

@Override
public void doFilter(ServletRequest req, ServletResponse res, 
   FilterChain filterChain) throws IOException, ServletException {
   String token = jwtTokenProvider.resolveToken((HttpServletRequest) req);
   if (token != null && jwtTokenProvider.validateToken(token)) {
      Authentication auth = token != null ?  
         jwtTokenProvider.getAuthentication(token) : null;
      SecurityContextHolder.getContext().setAuthentication(auth);
   }		
   filterChain.doFilter(req, res);
}

The JwtTokenFilter is called before the requests are processed. First, the token is read out of the HTTP header. Then it is checked if the token was found and if it is valid (validateToken(…)), then the authentication is created and set in the SecurityContextHolder.

The token validation looks like this:

public boolean validateToken(String token) {
   try {
      Jws<Claims> claimsJws = Jwts.parserBuilder()
        .setSigningKey(this.jwtTokenKey).build().parseClaimsJws(token);
      String subject = Optional.ofNullable(
        claimsJws.getBody().getSubject()).orElseThrow(() -> 
           new AuthenticationException("Invalid JWT token"));
      String uuid = Optional.ofNullable(claimsJws.getBody()
         .get(JwtUtils.UUID, String.class)).orElseThrow(() -> 
             new AuthenticationException("Invalid JWT token"));
      return this.loggedOutUsers.stream().noneMatch(myUserName -> 
         subject.equalsIgnoreCase(myUserName.userName) && 
         uuid.equals(myUserName.uuid));
   } catch (JwtException | IllegalArgumentException e) {
      throw new AuthenticationException("Expired or invalid JWT token",e);
   }
}

First, the token is parsed, the signing key is checked, and the claims are read: otherwise, an exception is thrown. Then the subject(userName) and UUID are read. Then the token is checked against the loggedOutUsers. If all the checks are ok, the token is valid, and the request gets processed.

Conclusion

The time to live for a token is 60 seconds. After a logout token is written in the revoked tokens table, the cache is updated every 90 seconds. The revoked token remains in the table for 185 seconds. That means every token will need to be refreshed during the time it is in all the caches. Then the refresh will fail and the token becomes useless. The rate limit on the logins makes sure that the number of entries a user can create in a revoked tokens table is limited. All of that limits the load on the RevokedToken database to increase the number of microservices it can handle.

With such an architecture, the risk from lost tokens can be limited while keeping most of the scalability advantages that come from the distributed security checks of JWT token-based authentication.

For the synchronized clocks in the microservices, NTP could be used. Here is a how-to. This article shows how an Angular frontend can handle the tokens.

.

Leave a Comment