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
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Component Tests for Spring Cloud Microservices
  • Optimizing High-Volume REST APIs Using Redis Caching and Spring Boot (With Load Testing Code)
  • Securing Verifiable Credentials With DPoP: A Spring Boot Implementation
  • Caching Mechanisms Using Spring Boot With Redis or AWS ElastiCache

Trending

  • AWS Kiro: The Agentic IDE That Makes Specs the Unit of Work
  • Architecting Sub-Microsecond HFT Systems With C++ and Zero-Copy IPC
  • Integrating AI-Driven Decision-Making in Agile Frameworks: A Deep Dive into Real-World Applications and Challenges
  • The Death of "Text-Only" ChatOps: Why Google's A2UI Matters for DevOps and SRE
  1. DZone
  2. Software Design and Architecture
  3. Microservices
  4. Stateless JWT Auth Microservice Architecture With Spring Boot 3 and Redis Sentinel

Stateless JWT Auth Microservice Architecture With Spring Boot 3 and Redis Sentinel

Design a stateless JWT auth service with Spring Boot 3, Redis caching, and Sentinel for high availability, faster token validation, and reduced DB load.

By 
Erkin Karanlık user avatar
Erkin Karanlık
·
May. 27, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
258 Views

Join the DZone community and get the full member experience.

Join For Free

In this article, I will discuss a highly available solution developed using Spring Boot 3 and Spring Security 6 to address the "centralized authentication method" problem frequently seen in modern microservice ecosystems.

We are not simply moving to an "authorization service"; we are examining the cache-first pattern, which minimizes DB usage, and the Redis Sentinel enhancement, which guarantees system persistence.

Why a Separate Authentication Service?

While embedding security into each service is an option in microservices, I have always found it more logical to proceed with a centralized Auth service and API Gateway combination.

  • DRY (Don't Repeat Yourself): Using token authentication logic in many services increases extra maintenance costs.
  • Isolation: Business services focus only on business logic; they don't deal with "is this token valid?" questions.
  • Performance: Thanks to the Redis connection, instead of going to the database with every request, we can resolve the validation via the cache in milliseconds.
Plain Text
 
[Client] ──► [API Gateway] ──► [Auth Service: validate token]
                                         │ (valid)
                                         ▼
                              [Backend Microservices]


Cache-Focused Approach: Reducing Database Load

In the classic workflow, every login request puts a load on the DB.

With the cache-first approach, the process proceeds like this with a POST /auth/signin request:

First, Redis is checked. If there is a valid and unexpired token for the user, it is replicated directly. In case of cache deficiency, AuthManager.authenticate() is activated, a DB query is sent, and a BCrypt check is performed.

After a successful login, a token is generated with JJWT (HS256). This token is given to Redis with our changes and TTL (e.g., 24 minutes), and personal responses are converted. In this way, it protects our main database, especially in brute-force or high-intensity login password attacks.

Plain Text
 
POST /auth/signin
        │
        ▼
┌──────────────────────────────┐
│  Token exists in Redis?      │──── YES ──► Return token (0 DB queries)
└──────────────────────────────┘
        │ NO
        ▼
┌──────────────────────────────┐
│  AuthManager.authenticate()  │  (DB query + BCrypt verification)
└──────────────────────────────┘
        │
        ▼
┌──────────────────────────────┐
│  Generate JWT (JJWT HS256)   │
└──────────────────────────────┘
        │
        ▼
┌──────────────────────────────┐
│  Write to Redis (TTL: 24 min)│
└──────────────────────────────┘
        │
        ▼
   Return token


Implementation Details

User Entity and UserDetails Integration

In most projects, unnecessary mappings are performed between the User asset and the UserDetails objects expected by Spring Security. To reduce complexity, the User Entity is directly derived from the UserDetails interface. This makes the code cleaner and makes it "native," as outlined by Spring Security.

Java
 
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "T_APP_USER")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_user_gen")
    @SequenceGenerator(name = "seq_user_gen", sequenceName = "SEQ_APP_USER", allocationSize = 1)
    @Column(name = "idx")
    private Long idx;

    @Column(name = "firstname")  private String firstName;
    @Column(name = "lastname")   private String lastName;
    @Column(unique = true, name = "email")   private String email;
    @Column(name = "accesskey")  private String accessKey;  // BCrypt-hashed

    @Column(name = "role")
    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override public String getUsername()              { return email; }
    @Override public String getPassword()              { return accessKey; }
    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled()               { return true; }
}


JWT Filter: The Gateway to Security

The request to the system passes through the OncePerRequestFilter.

Here, using JwtAuthenticationFilter, we parse the token in each request and populate the SecurityContext. By using the new SecurityFilterChain bean introduced with Spring Security 6, we have disabled CSRF and made session management completely stateless.

Token Generation and Validation

Java
 
public interface JwtService {
    String extractUserName(String token);
    String generateToken(UserDetails userDetails);
    boolean isTokenValid(String token, UserDetails userDetails);
}

@Service
public class JwtServiceImpl implements JwtService {

    @Value("${token.signing.key}")
    private String jwtSigningKey;  // Base64-encoded secret key

    @Override
    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    @Override
    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
            .setClaims(new HashMap<>())
            .setSubject(userDetails.getUsername())
            .setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24)) 
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    @Override
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return userName.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        return claimsResolver.apply(
            Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody()
        );
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSigningKey));
    }
}


High Availability: Redis Sentinel

Using a single Redis instance means that the Auth service has a "Single Point of Failure." If Redis crashes, no one can access the system. This risk mitigation was achieved using Redis Sentinel.

Thanks to the Sentinel structure: If the master node crashes, the dependent node is automatically promoted to master via failover. On the application side, we continuously manage these transitions using the Lettuce driver.

Technical Stack and Requirements

Technical stack and requirements


Redis Sentinel configuration:

Java
 
@Configuration
public class RedisConfig {

    @Value("${spring.redis.sentinel.master}")
    private String master;

    @Value("${spring.redis.sentinel.nodes}") 
    private String sentinelNodes;

    @Value("${spring.redis.password}")
    private String password;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration()
                .master(master);

        for (String node : sentinelNodes.split(",")) {
            String[] hostPort = node.split(":");
            sentinelConfig.sentinel(hostPort[0], Integer.parseInt(hostPort[1]));
        }

        sentinelConfig.setPassword(RedisPassword.of(password));
        return new LettuceConnectionFactory(sentinelConfig);
    }
}


Plain Text
 
yaml
  
 env:
   - name: spring.redis.sentinel.master
     valueFrom:
       secretKeyRef:
        name: redis-user-secret
         key: username
   - name: spring.redis.password
     valueFrom:
       secretKeyRef:
         name: redis-user-secret
         key: password


Token cache service:

Java
 
@Service
public class TokenCacheServiceImpl {

    private final RedisTemplate<String, String> redisTemplate;

    public TokenCacheServiceImpl(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheToken(String username, String token, long duration, TimeUnit unit) {
        redisTemplate.opsForValue().set(username, token, duration, unit);
    }

    @Cacheable(value = "tokens", key = "#username")
    public String getToken(String username) {
        return redisTemplate.opsForValue().get(username);
    }
}


Authentication service: signup and signin:

Java
 
@Service
@RequiredArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;
    private final TokenCacheServiceImpl tokenCacheService;

    @Override
    public JwtAuthenticationResponse signup(SignUpRequest request) {
        var user = User.builder()
            .firstName(request.getFirstName())
            .lastName(request.getLastName())
            .email(request.getEmail())
            .accessKey(passwordEncoder.encode(request.getAccessKey())) // BCrypt
            .role(Role.USER)
            .build();

        userRepository.save(user);
        var jwt = jwtService.generateToken(user);
        return JwtAuthenticationResponse.builder().token(jwt).build();
    }

    @Override
    public JwtAuthenticationResponse signin(SigninRequest request) {
        // 1. Check Redis cache first
        String cachedToken = tokenCacheService.getToken(request.getEmail());
        if (cachedToken != null) {
            return JwtAuthenticationResponse.builder().token(cachedToken).build();
        }

        // 2. If not cached, authenticate (DB + BCrypt)
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getEmail(), request.getAccessKey())
        );

        var user = userRepository.findByEmail(request.getEmail())
            .orElseThrow(() -> new IllegalArgumentException("Invalid credentials."));

        // 3. Generate token and write to Redis (24 min TTL)
        var jwt = jwtService.generateToken(user);
        tokenCacheService.cacheToken(request.getEmail(), jwt, 24, TimeUnit.MINUTES);
        return JwtAuthenticationResponse.builder().token(jwt).build();
    }
}


JWT authentication filter:

Java
 
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserService userService;

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

        final String authHeader = request.getHeader("Authorization");

        // Pass through if no Authorization header or doesn't start with Bearer
        if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7);
        final String userEmail = jwtService.extractUserName(jwt);

        // Process only if SecurityContext has no authentication yet
        if (StringUtils.isNotEmpty(userEmail)
                && SecurityContextHolder.getContext().getAuthentication() == null) {

            UserDetails userDetails = userService.userDetailsService()
                    .loadUserByUsername(userEmail);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                    );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                context.setAuthentication(authToken);
                SecurityContextHolder.setContext(context);
            }
        }

        filterChain.doFilter(request, response);
    }
}


Spring Security 6 configuration:

Java
 
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserService userService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)           // Stateless → no CSRF needed
            .authorizeHttpRequests(request -> request
                .requestMatchers("/auth/**").permitAll()      // Auth endpoints open to all
                .anyRequest().authenticated()
            )
            .sessionManagement(manager ->
                manager.sessionCreationPolicy(STATELESS)     // No server-side session
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthenticationFilter,        // JWT filter runs first
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService.userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }
}


Unit tests:

Java
 
@Test
@DisplayName("Signin: if token is cached, should not query the DB")
void testSignInWithCachedToken() {
    when(tokenCacheService.getToken(TEST_EMAIL)).thenReturn(TEST_TOKEN);

    JwtAuthenticationResponse response = authenticationService.signin(
        SigninRequest.builder().email(TEST_EMAIL).accessKey(TEST_PASSWORD).build()
    );

    assertEquals(TEST_TOKEN, response.getToken());
    verifyNoInteractions(authenticationManager);  // No DB + BCrypt call should happen
    verifyNoInteractions(userRepository);
}

// Invalid token test — SecurityContext should remain empty
@Test
@DisplayName("With an invalid token, SecurityContext should remain empty")
void testDoFilterInternalInvalidToken() throws Exception {
    when(request.getHeader("Authorization")).thenReturn("Bearer " + INVALID_TOKEN);
    when(jwtService.extractUserName(INVALID_TOKEN)).thenReturn(TEST_EMAIL);
    when(userService.userDetailsService()).thenReturn(userDetailsService);
    when(userDetailsService.loadUserByUsername(TEST_EMAIL)).thenReturn(userDetails);
    when(jwtService.isTokenValid(INVALID_TOKEN, userDetails)).thenReturn(false);

    jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);

    verify(filterChain).doFilter(request, response);
    assertNull(SecurityContextHolder.getContext().getAuthentication());
}


Summary and Conclusion

With the purchasing architecture, not only a secure login screen; It has built an architecture that is extremely scalable, overcomes database bottlenecks with caching, and meets high availability (HA) standards. 

In particular, the modern architecture offered by Spring Boot 3 has made the security layer much more flexible. If you are starting a large-scale microservice project, you can design token management from the outset in this "stateless" and "cached" manner.

Spring Security microservice Redis (company) Spring Boot

Opinions expressed by DZone contributors are their own.

Related

  • Component Tests for Spring Cloud Microservices
  • Optimizing High-Volume REST APIs Using Redis Caching and Spring Boot (With Load Testing Code)
  • Securing Verifiable Credentials With DPoP: A Spring Boot Implementation
  • Caching Mechanisms Using Spring Boot With Redis or AWS ElastiCache

Partner Resources

×

Comments

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

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

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 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook