DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Last call! Secure your stack and shape the future! Help dev teams across the globe navigate their software supply chain security challenges.

Modernize your data layer. Learn how to design cloud-native database architectures to meet the evolving demands of AI and GenAI workloads.

Releasing software shouldn't be stressful or risky. Learn how to leverage progressive delivery techniques to ensure safer deployments.

Avoid machine learning mistakes and boost model performance! Discover key ML patterns, anti-patterns, data strategies, and more.

Related

  • Providing Enum Consistency Between Application and Data
  • Testcontainers With Kotlin and Spring Data R2DBC
  • Manage Microservices With Docker Compose
  • Build a Java Microservice With AuraDB Free

Trending

  • Unlocking AI Coding Assistants Part 3: Generating Diagrams, Open API Specs, And Test Data
  • Beyond ChatGPT, AI Reasoning 2.0: Engineering AI Models With Human-Like Reasoning
  • Cookies Revisited: A Networking Solution for Third-Party Cookies
  • The Cypress Edge: Next-Level Testing Strategies for React Developers
  1. DZone
  2. Data Engineering
  3. Data
  4. Scalable JWT Token Revocation in Spring Boot

Scalable JWT Token Revocation in Spring Boot

Learn how to limit the risk from lost tokens while keeping scalability advantages that come from distributed security checks of JWT token-based authentication.

By 
Sven Loesekann user avatar
Sven Loesekann
·
Updated Apr. 21, 22 · Tutorial
Likes (5)
Comment
Save
Tweet
Share
8.2K Views

Join the DZone community and get the full member experience.

Join For Free

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

Revoked Tokens DB 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:

Java
 
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 role Optional 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 logout is implemented in the UserDetailsMgmt service:

Java
 
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:

Java
 
@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: 

Java
 
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:

Java
 
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:

Java
 
@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:

Java
 
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.

Database Time to live JWT (JSON Web Token) microservice Spring Boot Data Types

Opinions expressed by DZone contributors are their own.

Related

  • Providing Enum Consistency Between Application and Data
  • Testcontainers With Kotlin and Spring Data R2DBC
  • Manage Microservices With Docker Compose
  • Build a Java Microservice With AuraDB Free

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!