Skip to main content

JWT authentication

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, commit(s) JWT authentication (Java), JWT authentication (Kotlin), JWT authentication (Groovy)

In the previous documents we covered what Spring Security handles, what it does not, and how the filter chain architecture works under the hood. Now let's put that theory into practice by adding stateless JWT authentication so the API can tell the difference between anonymous readers and authenticated staff members.

File overview

Files to Create/Modify
File Tree
.
├── build.gradle
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ ├── security
│ │ │ │ ├── SecurityConfig.java
│ │ │ │ ├── handler
│ │ │ │ │ ├── AuthenticationErrorResponseWriter.java
│ │ │ │ │ ├── CustomAccessDeniedHandler.java
│ │ │ │ │ └── CustomAuthenticationEntryPoint.java
│ │ │ │ ├── jwt
│ │ │ │ │ ├── JwtAuthenticationFilter.java
│ │ │ │ │ ├── JwtProperties.java
│ │ │ │ │ └── JwtService.java
│ │ │ │ └── userdetails
│ │ │ │ ├── SakilaUserDetails.java
│ │ │ │ └── SakilaUserDetailsService.java
│ │ │ └── web
│ │ │ └── ControllerAdvice.java
│ │ └── sakila
│ │ ├── auth
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── AuthRestController.java
│ │ │ │ └── AuthRestMapper.java
│ │ │ └── domain
│ │ │ ├── port
│ │ │ │ └── in
│ │ │ │ └── AuthUseCases.java
│ │ │ └── service
│ │ │ └── AuthUseCasesImpl.java
│ │ └── staff
│ │ ├── adapter
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── StaffJpaMapper.java
│ │ │ ├── StaffJpaRepository.java
│ │ │ └── StaffRepositoryImpl.java
│ │ └── domain
│ │ ├── model
│ │ │ └── Staff.java
│ │ └── port
│ │ └── out
│ │ └── StaffRepository.java
│ └── resources
│ ├── application.yaml
│ ├── openapi.yaml
│ ├── sakila-data.sql
│ └── sakila-schema.sql
└── test
└── java
└── dev
└── pollito
└── spring_java
├── config
│ └── security
│ ├── SecurityConfigMockMvcTest.java
│ ├── jwt
│ │ ├── JwtAuthenticationFilterTest.java
│ │ └── JwtServiceTest.java
│ └── userdetails
│ └── SakilaUserDetailsServiceTest.java
└── sakila
├── auth
│ ├── adapter
│ │ └── in
│ │ └── rest
│ │ └── AuthRestControllerMockMvcTest.java
│ └── domain
│ └── service
│ └── AuthUseCasesImplTest.java
└── film
└── adapter
└── in
└── rest
└── FilmRestControllerMockMvcTest.java
info

This document will not go deep into how the Staff slice (entity, repository, mapper, and domain model) is built. It follows the same pattern used for the Film slice in the persistence integration documents.

Dependencies

We need Spring Security for the filter chain and JJWT for token generation and validation. The dependencies are added to the build file:

build.gradle
def jjwtVersion = '0.12.6'
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
  • spring-boot-starter-security brings in Spring Security and its filter chain.
  • jjwt-api, jjwt-impl, and jjwt-jackson handle token creation and parsing.
  • spring-security-test gives us helpers for testing secured endpoints.

Password storage compatibility

The original Sakila sample database stores staff passwords using MD5/SHA1 hashes. Spring Security 6.x does not support these algorithms out of the box, so we migrated them to BCrypt using a Flyway migration:

V5__migrate_staff_passwords_to_bcrypt.sql
ALTER TABLE staff ALTER COLUMN password TYPE VARCHAR(60);

UPDATE staff SET password = '$2a$10$BuPx936L/1mmFsSEvFUanOR/dvE4nhAciLvCovbSZQxVbVA9gVDrO' WHERE staff_id = 1;
UPDATE staff SET password = '$2a$10$Gw380J97F3u0AfFfvNkJUOhbrGcaUsL9oaRoyMaoPmr07ovBLodBe' WHERE staff_id = 2;
caution

If you maintain separate data and schema scripts for development and testing (for example, sakila-data.sql and sakila-schema.sql), make sure you update them with BCrypt-encoded passwords as well. Otherwise, integration tests and local runs will fail during authentication.

JWT configuration

We externalize the secret and expiration into application.yaml so we can rotate keys without recompiling:

resources/application.yaml
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000

A typed properties object binds those values at runtime:

config/security/jwt/JwtProperties.java
package dev.pollito.spring_java.config.security.jwt;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(String secret, long expirationMs) {}
How to create a secure JWT secret

In production, your JWT_SECRET should be a cryptographically secure random string of at least 256 bits (32 bytes).

Best practices

AspectRecommendation
LengthAt least 32 bytes (256 bits). For HS256/HS512, longer is fine but 32+ is the minimum.
GenerationUse a cryptographically secure random generator. Examples: openssl rand -hex 32 or openssl rand -base64 32
StorageStore it as an environment variable (which your project already does via ${JWT_SECRET}) — never commit it to Git.
RotationHave a strategy to rotate it periodically without invalidating all active sessions immediately (e.g., support key IDs / kid headers).
UniquenessUse a different secret per environment (dev, staging, prod).

Quick generation commands

# 32 bytes as hex (64 hex chars)
openssl rand -hex 32

# 32 bytes as base64
openssl rand -base64 32

Security configuration

SecurityConfig is the heart of the setup. It disables CSRF (we are stateless), sets session management to STATELESS, and defines authorization rules:

  • GET /api/films/** is public.
  • POST /api/auth/login is public.
  • requestMatchers("/actuator/**") is public.
  • requestMatchers("/h2-console/**") is public.
  • requestMatchers("/error") is public.
  • Everything else requires authentication.

It also wires the JwtAuthenticationFilter before Spring's UsernamePasswordAuthenticationFilter so every incoming request gets its token inspected first.

config/security/SecurityConfig.java
package dev.pollito.spring_java.config.security;

import static org.springframework.http.HttpMethod.GET;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;

import dev.pollito.spring_java.config.security.handler.CustomAccessDeniedHandler;
import dev.pollito.spring_java.config.security.handler.CustomAuthenticationEntryPoint;
import dev.pollito.spring_java.config.security.jwt.JwtAuthenticationFilter;
import dev.pollito.spring_java.config.security.jwt.JwtProperties;
import dev.pollito.spring_java.config.security.jwt.JwtService;
import dev.pollito.spring_java.config.security.userdetails.SakilaUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(JwtProperties.class)
@RequiredArgsConstructor
public class SecurityConfig {

private final SakilaUserDetailsService sakilaUserDetailsService;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;

@Bean
public SecurityFilterChain securityFilterChain(
@NonNull HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(
auth ->
auth.requestMatchers("/actuator/**")
.permitAll()
.requestMatchers("/h2-console/**")
.permitAll()
.requestMatchers("/error")
.permitAll()
.requestMatchers(GET, "/api/films/**")
.permitAll()
.requestMatchers("/api/auth/login")
.permitAll()
.anyRequest()
.authenticated())
.exceptionHandling(
ex ->
ex.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(
JwtService jwtService,
org.springframework.security.core.userdetails.UserDetailsService userDetailsService) {
return new JwtAuthenticationFilter(jwtService, userDetailsService);
}

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

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

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

JWT service

JwtService handles token creation, username extraction, and validation. It uses the JJWT builder API to sign tokens with an HMAC key derived from the configured secret:

config/security/jwt/JwtService.java
package dev.pollito.spring_java.config.security.jwt;

import static io.jsonwebtoken.Jwts.parser;
import static io.jsonwebtoken.security.Keys.hmacShaKeyFor;
import static java.nio.charset.StandardCharsets.UTF_8;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import java.util.Date;
import java.util.function.Function;
import javax.crypto.SecretKey;
import org.jspecify.annotations.NonNull;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

@Service
public class JwtService {

private final JwtProperties jwtProperties;

public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}

public String generateToken(@NonNull UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtProperties.expirationMs()))
.signWith(getSignInKey())
.compact();
}

public boolean isTokenValid(String token, @NonNull UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername()) && !isTokenExpired(token);
}

public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}

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

private <T> T extractClaim(String token, @NonNull Function<Claims, T> claimsResolver) {
try {
return claimsResolver.apply(
parser().verifyWith(getSignInKey()).build().parseSignedClaims(token).getPayload());
} catch (ExpiredJwtException e) {
return claimsResolver.apply(e.getClaims());
}
}

private SecretKey getSignInKey() {
return hmacShaKeyFor(jwtProperties.secret().getBytes(UTF_8));
}
}

Authentication filter

JwtAuthenticationFilter is a servlet filter that runs once per request. It looks for an Authorization header starting with Bearer , extracts the token, validates it against the user details service, and populates the security context if everything checks out:

config/security/jwt/JwtAuthenticationFilter.java
package dev.pollito.spring_java.config.security.jwt;

import static org.springframework.security.core.context.SecurityContextHolder.getContext;

import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtService jwtService;
private final UserDetailsService userDetailsService;

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}

String token = authHeader.substring(7);
String username;
try {
username = jwtService.extractUsername(token);
} catch (JwtException e) {
throw new InsufficientAuthenticationException("Invalid or malformed JWT token", e);
}

if (username == null || getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}

UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (!jwtService.isTokenValid(token, userDetails)) {
filterChain.doFilter(request, response);
return;
}

UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
getContext().setAuthentication(authToken);

filterChain.doFilter(request, response);
}
}

User details

Spring Security needs a UserDetailsService to look up users by username. We implemented SakilaUserDetailsService backed by a StaffRepository, and SakilaUserDetails adapts the Staff domain model to Spring's UserDetails contract:

config/security/userdetails/SakilaUserDetails.java
package dev.pollito.spring_java.config.security.userdetails;

import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import java.util.Collection;
import java.util.Collections;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public record SakilaUserDetails(Staff staff) implements UserDetails {

@Override
public String getUsername() {
return staff.getUsername();
}

@Override
public String getPassword() {
return staff.getPassword();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_STAFF"));
}

@Override
public boolean isAccountNonLocked() {
return staff.isActive();
}
}
config/security/userdetails/SakilaUserDetailsService.java
package dev.pollito.spring_java.config.security.userdetails;

import dev.pollito.spring_java.sakila.staff.domain.port.out.StaffRepository;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SakilaUserDetailsService implements UserDetailsService {

private final StaffRepository staffRepository;

@Override
public UserDetails loadUserByUsername(@NonNull String username) throws UsernameNotFoundException {
// Composite lookup: tries staff first, ready for future customer fallback.
// Example: .or(() -> customerRepository.findByUsername(username).map(SakilaUserDetails::new))
// Both staff and customers will share /api/auth/login.
return staffRepository
.findByUsername(username)
.map(SakilaUserDetails::new)
.orElseThrow(() -> new UsernameNotFoundException(username));
}
}

Auth endpoints

The AuthRestController exposes two endpoints: POST /api/auth/login to authenticate and receive a token, and GET /api/auth/me to retrieve the current user's details. It implements the AuthApi interface generated from the OpenAPI spec.

We also updated openapi.yaml with a bearerAuth security scheme and applied it to the mutating film endpoints (POST, PUT, DELETE). The GET endpoints remain open:

sakila/auth/adapter/in/rest/AuthRestController.java
package dev.pollito.spring_java.sakila.auth.adapter.in.rest;

import static io.opentelemetry.api.trace.Span.current;
import static java.time.OffsetDateTime.now;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.ResponseEntity.ok;

import dev.pollito.spring_java.sakila.auth.domain.port.in.AuthUseCases;
import dev.pollito.spring_java.sakila.generated.api.AuthApi;
import dev.pollito.spring_java.sakila.generated.model.LoginRequest;
import dev.pollito.spring_java.sakila.generated.model.LoginResponse;
import dev.pollito.spring_java.sakila.generated.model.LoginResponseAllOfData;
import dev.pollito.spring_java.sakila.generated.model.UserDetailsResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AuthRestController implements AuthApi {

private final AuthUseCases authUseCases;
private final AuthRestMapper mapper;
private final HttpServletRequest request;

@Override
public ResponseEntity<LoginResponse> login(@NonNull LoginRequest loginRequest) {
return ok(
new LoginResponse()
.data(
new LoginResponseAllOfData()
.token(
authUseCases.authenticate(
loginRequest.getUsername(), loginRequest.getPassword())))
.instance(request.getRequestURI())
.status(OK.value())
.timestamp(now())
.trace(current().getSpanContext().getTraceId()));
}

@Override
public ResponseEntity<UserDetailsResponse> getCurrentUserDetails() {
return ok(
new UserDetailsResponse()
.data(mapper.map(authUseCases.getCurrentUser()))
.instance(request.getRequestURI())
.timestamp(now())
.trace(current().getSpanContext().getTraceId())
.status(OK.value()));
}
}

Error handling for auth failures

To keep error responses consistent with the rest of the API, we added an @ExceptionHandler(AuthenticationException.class) to the existing ControllerAdvice and wired custom AuthenticationEntryPoint and AccessDeniedHandler implementations. They reuse the same Problem Details JSON format that error handling introduced.

config/web/ControllerAdvice.java
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Error> handle(AuthenticationException e) {
return buildProblemDetail(e, UNAUTHORIZED);
}

Testing

Every component introduced in this document has a corresponding test class. The suite covers unit tests for the JWT service, the authentication filter, the user-details loader, and the security rules, plus MockMvc-based integration tests for the REST controller and the security configuration.

tip

Pre-existing @WebMvcTest classes that do not exercise the security filter chain should be annotated with @AutoConfigureMockMvc(addFilters = false) so that JwtAuthenticationFilter does not interfere with the test setup.

JwtAuthenticationFilter

config/security/jwt/JwtAuthenticationFilterTest.java
package dev.pollito.spring_java.config.security.jwt;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.springframework.security.core.context.SecurityContextHolder.clearContext;
import static org.springframework.security.core.context.SecurityContextHolder.getContext;

import io.jsonwebtoken.MalformedJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

@ExtendWith(MockitoExtension.class)
class JwtAuthenticationFilterTest {

@Mock private JwtService jwtService;
@Mock private UserDetailsService userDetailsService;
@Mock private HttpServletRequest request;
@Mock private HttpServletResponse response;
@Mock private FilterChain filterChain;
@InjectMocks private JwtAuthenticationFilter filter;

@BeforeEach
void setUp() {
clearContext();
}

@AfterEach
void tearDown() {
clearContext();
}

@Test
void doFilterInternal_noAuthHeader_proceedsChain() throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn(null);

filter.doFilterInternal(request, response, filterChain);

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

@Test
void doFilterInternal_authHeaderWithoutBearer_proceedsChain()
throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Basic dXNlcjpwYXNz");

filter.doFilterInternal(request, response, filterChain);

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

@Test
void doFilterInternal_malformedToken_throwsInsufficientAuthenticationException() {
when(request.getHeader("Authorization")).thenReturn("Bearer invalid-token");
when(jwtService.extractUsername("invalid-token")).thenThrow(new MalformedJwtException("bad"));

assertThrows(
InsufficientAuthenticationException.class,
() -> filter.doFilterInternal(request, response, filterChain));
}

@Test
void doFilterInternal_nullUsername_proceedsChain() throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn(null);

filter.doFilterInternal(request, response, filterChain);

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

@Test
void doFilterInternal_existingAuthentication_proceedsChain()
throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn("Mike");

Authentication existingAuth = mock(Authentication.class);
getContext().setAuthentication(existingAuth);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
assertSame(existingAuth, getContext().getAuthentication());
}

@Test
void doFilterInternal_invalidToken_proceedsChain() throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn("Mike");

UserDetails userDetails =
User.builder()
.username("Mike")
.password("password")
.authorities(Collections.emptyList())
.build();
when(userDetailsService.loadUserByUsername("Mike")).thenReturn(userDetails);
when(jwtService.isTokenValid("token", userDetails)).thenReturn(false);

filter.doFilterInternal(request, response, filterChain);

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

@Test
void doFilterInternal_validToken_setsAuthenticationAndProceedsChain()
throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn("Mike");

UserDetails userDetails =
User.builder()
.username("Mike")
.password("password")
.authorities(Collections.emptyList())
.build();
when(userDetailsService.loadUserByUsername("Mike")).thenReturn(userDetails);
when(jwtService.isTokenValid("token", userDetails)).thenReturn(true);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);

Authentication auth = getContext().getAuthentication();
assertNotNull(auth);
assertEquals(userDetails, auth.getPrincipal());
assertTrue(auth.isAuthenticated());
}
}

JwtService

config/security/jwt/JwtServiceTest.java
package dev.pollito.spring_java.config.security.jwt;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.security.core.userdetails.User.withUsername;

import org.junit.jupiter.api.Test;
import org.springframework.security.core.userdetails.UserDetails;

class JwtServiceTest {

private final JwtProperties jwtProperties =
new JwtProperties("this-is-a-test-secret-key-make-it-long", 3600000);
private final JwtService jwtService = new JwtService(jwtProperties);

@Test
void generateTokenAndValidate() {
UserDetails userDetails = withUsername("Mike").password("password").roles("STAFF").build();
String token = jwtService.generateToken(userDetails);
assertNotNull(token);
assertTrue(jwtService.isTokenValid(token, userDetails));
assertEquals("Mike", jwtService.extractUsername(token));
}

@Test
void invalidTokenFailsValidation() {
UserDetails userDetails = withUsername("Mike").password("password").roles("STAFF").build();
String token = jwtService.generateToken(userDetails);
UserDetails otherUser = withUsername("Jon").password("password").roles("STAFF").build();
assertFalse(jwtService.isTokenValid(token, otherUser));
}

@Test
void expiredTokenFailsValidation() {
JwtProperties expiredProperties =
new JwtProperties("this-is-a-test-secret-key-make-it-long", -1);
JwtService expiredJwtService = new JwtService(expiredProperties);
UserDetails userDetails = withUsername("Mike").password("password").roles("STAFF").build();
String token = expiredJwtService.generateToken(userDetails);
assertFalse(expiredJwtService.isTokenValid(token, userDetails));
}
}

SakilaUserDetailsService

config/security/userdetails/SakilaUserDetailsServiceTest.java
package dev.pollito.spring_java.config.security.userdetails;

import static java.util.Optional.empty;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import dev.pollito.spring_java.sakila.staff.domain.port.out.StaffRepository;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@ExtendWith(MockitoExtension.class)
class SakilaUserDetailsServiceTest {

@Mock private StaffRepository staffRepository;

@InjectMocks private SakilaUserDetailsService sakilaUserDetailsService;

@Test
void loadUserByUsername() {
Staff staff =
Staff.builder()
.id(1)
.username("Mike")
.password("encoded")
.firstName("Mike")
.lastName("Hillyer")
.active(true)
.build();
when(staffRepository.findByUsername("Mike")).thenReturn(Optional.of(staff));

SakilaUserDetails userDetails =
(SakilaUserDetails) sakilaUserDetailsService.loadUserByUsername("Mike");

assertEquals("Mike", userDetails.getUsername());
assertEquals("encoded", userDetails.getPassword());
assertTrue(userDetails.isAccountNonLocked());
assertEquals(staff, userDetails.staff());
}

@Test
void loadUserByUsernameNotFound() {
when(staffRepository.findByUsername("Unknown")).thenReturn(empty());
assertThrows(
UsernameNotFoundException.class,
() -> sakilaUserDetailsService.loadUserByUsername("Unknown"));
}
}

SecurityConfig

config/security/SecurityConfigMockMvcTest.java
package dev.pollito.spring_java.config.security;

import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class SecurityConfigMockMvcTest {

@Autowired private MockMvc mockMvc;

private void assertEndpoint(
@NonNull HttpMethod method, String path, String body, int expectedStatus) throws Exception {
MockHttpServletRequestBuilder request =
switch (method.name()) {
case "GET" -> get(path);
case "POST" -> post(path);
default -> throw new IllegalArgumentException("Unsupported method: " + method);
};

if (body != null && !body.isBlank()) {
request = request.contentType(APPLICATION_JSON).content(body);
}

mockMvc.perform(request.accept(APPLICATION_JSON)).andExpect(status().is(expectedStatus));
}

@Nested
@DisplayName("Film endpoints")
class Films {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /api/films | | 200
GET | /api/films/1 | | 200
POST | /api/films | {} | 401
""")
void filmEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("Auth endpoints")
class Auth {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
POST | /api/auth/login | {"username":"Mike","password":"password"} | 200
GET | /api/auth/me | | 401
""")
void authEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("Actuator endpoints")
class Actuator {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /actuator/health | | 200
""")
void actuatorEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("H2 Console")
class H2Console {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /h2-console | | 404
""")
void h2ConsoleEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("Error endpoint")
class ErrorEndpoint {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /error | | 500
""")
void errorEndpoint(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}
}

AuthRestController

sakila/auth/adapter/in/rest/AuthRestControllerMockMvcTest.java
package dev.pollito.spring_java.sakila.auth.adapter.in.rest;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import dev.pollito.spring_java.config.security.userdetails.SakilaUserDetails;
import dev.pollito.spring_java.sakila.auth.domain.port.in.AuthUseCases;
import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(AuthRestController.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({AuthRestMapperImpl.class})
class AuthRestControllerMockMvcTest {

@Autowired private MockMvc mockMvc;

@MockitoBean private AuthUseCases authUseCases;

@MockitoSpyBean private AuthRestMapper authRestMapper;

@MockitoBean private UserDetailsService userDetailsService;

@Test
void loginReturnsToken() throws Exception {
when(authUseCases.authenticate(any(), any())).thenReturn("jwt-token-123");

mockMvc
.perform(
post("/api/auth/login")
.contentType(APPLICATION_JSON)
.content("{\"username\":\"Mike\",\"password\":\"password\"}")
.accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.token").value("jwt-token-123"));
}

static @NonNull Stream<SakilaUserDetails> getCurrentUserDetailsReturnsUserDetailsArgs() {
return Stream.of(
new SakilaUserDetails(
Staff.builder().id(1).firstName("Mike").active(true).username("Mike").build()),
null);
}

@ParameterizedTest
@MethodSource("getCurrentUserDetailsReturnsUserDetailsArgs")
void getCurrentUserDetailsReturnsUserDetails(SakilaUserDetails sakilaUserDetails)
throws Exception {
when(authUseCases.getCurrentUser()).thenReturn(sakilaUserDetails);

var resultActions =
mockMvc.perform(get("/api/auth/me").accept(APPLICATION_JSON)).andExpect(status().isOk());

if (sakilaUserDetails != null) {
resultActions
.andExpect(jsonPath("$.data.username").value("Mike"))
.andExpect(jsonPath("$.data.accountNonLocked").value(true))
.andExpect(jsonPath("$.data.authorities[0]").value("ROLE_STAFF"))
.andExpect(jsonPath("$.data.staff.username").value("Mike"));
}
}
}

AuthUseCasesImpl

sakila/auth/domain/service/AuthUseCasesImplTest.java
package dev.pollito.spring_java.sakila.auth.domain.service;

import static java.util.Collections.emptyList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.security.core.context.SecurityContextHolder.clearContext;
import static org.springframework.security.core.context.SecurityContextHolder.getContext;

import dev.pollito.spring_java.config.security.jwt.JwtService;
import dev.pollito.spring_java.config.security.userdetails.SakilaUserDetails;
import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

@ExtendWith(MockitoExtension.class)
class AuthUseCasesImplTest {

@Mock private AuthenticationManager authenticationManager;
@Mock private JwtService jwtService;
@InjectMocks private AuthUseCasesImpl authUseCases;

@BeforeEach
void setUp() {
clearContext();
}

@AfterEach
void tearDown() {
clearContext();
}

private static @NonNull Stream<Arguments> provideInvalidAuthentications() {
Authentication authentication = mock(Authentication.class);
when(authentication.getPrincipal()).thenReturn("not-a-user-details");
return Stream.of(Arguments.of((Authentication) null), Arguments.of(authentication));
}

@Test
void authenticateReturnsToken() {
UserDetails userDetails =
User.builder().username("Mike").password("password").authorities(emptyList()).build();
String token = "jwt-token-123";
Authentication authentication = mock(Authentication.class);

when(authentication.getPrincipal()).thenReturn(userDetails);
when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
.thenReturn(authentication);
when(jwtService.generateToken(userDetails)).thenReturn(token);

assertEquals(token, authUseCases.authenticate("Mike", "password"));
}

@Test
void getCurrentUserReturnsUser() {
Staff staff = Staff.builder().id(1).username("Mike").active(true).build();
SakilaUserDetails userDetails = new SakilaUserDetails(staff);
Authentication authentication = mock(Authentication.class);

when(authentication.getPrincipal()).thenReturn(userDetails);
getContext().setAuthentication(authentication);

SakilaUserDetails result = authUseCases.getCurrentUser();

assertEquals("Mike", result.getUsername());
assertTrue(result.isAccountNonLocked());
assertEquals(staff, result.staff());
}

@ParameterizedTest
@MethodSource("provideInvalidAuthentications")
void getCurrentUserInvalidAuthenticationThrowsIllegalStateException(
Authentication authentication) {
getContext().setAuthentication(authentication);

IllegalStateException ex =
assertThrows(IllegalStateException.class, () -> authUseCases.getCurrentUser());

assertEquals("No authenticated user found", ex.getMessage());
}
}

Try it out

Authentication example

Terminal
$ curl -s 'POST' \
'http://localhost:8080/api/auth/login' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"username": "Mike",
"password": "password"
}' | jq
{
"instance": "/api/auth/login",
"status": 200,
"timestamp": "2026-04-28T22:55:22.915386475+01:00",
"trace": "89784b93d7c8e7e5fc5dc26175d8315f",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJNaWtlIiwiaWF0IjoxNzc3NDEzMzIyLCJleHAiOjE3Nzc0MTY5MjJ9.BJHNKRefBZHF8iRU9cS7N2hyQvo5MtMIrPG0IcOZGhE"
}
}
Scroll to zoom • Drag corner to resize

Authorized example

Retrieve current authenticated user

Terminal
$ curl -s 'GET' \
'http://localhost:8080/api/auth/me' \
-H 'accept: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJNaWtlIiwiaWF0IjoxNzc3NDE0MjQyLCJleHAiOjE3Nzc0MTc4NDJ9.uvSsC412-2tX5ab_lAQFeSG3BrnzJw1JQj9Msr320ls' | jq
{
"instance": "/api/auth/me",
"status": 200,
"timestamp": "2026-04-28T23:11:30.415677563+01:00",
"trace": "e9ef841cc7107f2acc62d5a0bf25b422",
"data": {
"username": "Mike",
"accountNonLocked": true,
"authorities": [
"ROLE_STAFF"
],
"staff": {
"id": 1,
"firstName": "Mike",
"lastName": "Hillyer",
"username": "Mike",
"active": true,
"email": "Mike.Hillyer@sakilastaff.com"
}
}
}
Scroll to zoom • Drag corner to resize

Unauthorized example

Missing Authorization header

Terminal
$ curl -s 'GET' \
'http://localhost:8080/api/auth/me' \
-H 'accept: application/json' | jq
{
"title": "Unauthorized",
"detail": "Full authentication is required to access this resource",
"instance": "/api/auth/me",
"status": 401,
"timestamp": "2026-04-28T23:24:03.462397829+01:00",
"trace": "0b6455353a71f296304ce5a0814d1f49"
}
Scroll to zoom • Drag corner to resize

Invalid Authorization token

Terminal
$ curl -s 'GET' \
'http://localhost:8080/api/auth/me' \
-H 'accept: application/json' \
-H 'Authorization: Bearer non-valid-token' | jq
{
"title": "Unauthorized",
"detail": "Invalid or malformed JWT token",
"instance": "/api/auth/me",
"status": 401,
"timestamp": "2026-04-28T23:28:28.845341983+01:00",
"trace": "2ff938e56d19299321753f40e60ceb20"
}
Scroll to zoom • Drag corner to resize