Simplest JWT implementation in Spring Boot

Ashish Kumar
5 min readAug 19, 2024

In this blog we will look into how can we implement role based access control(RBAC) using JWT. If you want to leverage JWT to protect your APIs instead of session based authentication(which spring security provides by default) then in this article I will show you the simplest approach.

No third party library is required. Only spring dependency is sufficient to implement JWT authentication in you application. No need of complex JWT filter or io.jsonwebtoken library

Dependencies

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
</dependencies>

You must be wondering why I choose the oauth2-resource server as the dependency. Let me clarify that. This dependency already includes spring security core dependency. It also has the JWT encoding and decoding features provided nimbus-jose-jwt library.

In normal cases oauth2 resource server is used in oauth flow where resource server validates the JWT token by invoking the token validation API of authorization server.
We will change overwrite this behavior by providing the local JWT decoder

Security Configuration

@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {

@Value("${jwt.key}")
private String jwtKey;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
req -> req.requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/**").authenticated()
.anyRequest().permitAll()
)
.sessionManagement(sessionManagement ->
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer
.jwt(jwt ->
jwt.decoder(jwtDecoder()).jwtAuthenticationConverter(customJwtAuthConverter())
)

)
.httpBasic(Customizer.withDefaults())
.build();
}

@Bean
JwtEncoder jwtEncoder() {
return new NimbusJwtEncoder(new ImmutableSecret<>(jwtKey.getBytes()));
}

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

@Bean
public JwtDecoder jwtDecoder() {
byte[] bytes = jwtKey.getBytes();
SecretKeySpec originalKey = new SecretKeySpec(bytes, 0, bytes.length, "RSA");
return NimbusJwtDecoder.withSecretKey(originalKey).macAlgorithm(MacAlgorithm.HS512).build();
}

@Bean
public JwtAuthenticationConverter customJwtAuthConverter() {
return new CustomJwtAuthenticationConverter("ROLE_", "roles");
}

}

In above Security Configuration class following this are configured

  1. Login endpoint is allowed to public.
  2. All other endpoints are secured
  3. We have enabled method level security using annotation @EnableMethodSecurity
  4. In SecurityFilterChain builder everything is similar to any other JWT security filter chain except the oauth2ResourceServer configuration.
  5. In oauth2ResourceServer configuration we are telling that use local decoder to decoder the configuration.
  6. We have configured the JWT encoder and decoder using the provided security key. For this demo I have defined it in application.properties however in real scenario it can be supplied using the environment variable.

7. More on the usages of key next section.

8. We also have configured custom JWT converter. Default one uses scope as the authorities claim and also SCOPE_ as the authorities prefix. To over write it to role we have created this custom converter bean and plugged it into oauth2ResourceServer

public class CustomJwtAuthenticationConverter extends JwtAuthenticationConverter {
public CustomJwtAuthenticationConverter(String authorityPrefix, String authorityClaimName) {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix(authorityPrefix);
grantedAuthoritiesConverter.setAuthoritiesClaimName(authorityClaimName);
this.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
}

}

Login and Token creation

@PostMapping("/login")
public LoginResponse loginUser(@RequestBody UserLoginDto loginDto) {
return userService.loginUser(loginDto);
}
public LoginResponse loginUser(UserLoginDto loginDto) {
log.info("Login request for user {}", loginDto.username());
Users user = userRepository.findByUsername(loginDto.username())
.orElseThrow(() -> new UnAuthorizedException("Username does not exists"));

if (!passwordUtil.matchPasswords(loginDto.password(), user.getPasswordHash())) {
throw new UnAuthorizedException("Password does not match.");
}
String token = jwtUtil.generateToken(user);
return LoginResponse.builder()
.token(token)
.build();
}
public String generateToken(Users user) {
Instant now = Instant.now();
String role = user.getRole().name();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("Aarash")
.issuedAt(now)
.expiresAt(now.plus(expirationHours, ChronoUnit.HOURS))
.subject(user.getUsername())
.claim("roles", role)
.claim("username", user.getUsername())
.claim("email", user.getEmail())
.claim("firstName", user.getFirstName())
.claim("lastName", user.getLastName())
.build();
JwtEncoderParameters encoderParameters = JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS512).build(), claims);
return this.encoder.encode(encoderParameters).getTokenValue();
}

From the login API we are calling loginUser method of userService object. In this method after doing basic validation we are invoking the generateToken method.

We are building the JWT claims and using JWT encoder bean(configured in security class) to encode the token using provided claims and HS512 encryption algorithm.

Any key like secret will not work. The length of the symmetric key depends on the hashing algorithm we are using. Since we are using HS512, which is a HMAC with SHA-512, the key should be at least 512 bits (64 bytes) long. That’s why we have configured a long key in application.properties. 128 byte key is recommended.

API security

public class UserController {

private final UserService userService;

@PostMapping("/register")
@PreAuthorize("hasRole('ADMIN')")
public Users registerUser(@RequestBody UserRegisterDto registerDto) {
return userService.registerUser(registerDto);
}


@PostMapping("/login")
public LoginResponse loginUser(@RequestBody UserLoginDto loginDto) {
return userService.loginUser(loginDto);
}

public Users me() {
return userService.me();
}

@GetMapping("/profile/{username}")
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public Users userProfile(@PathVariable String username) {
return userService.me();
}

@DeleteMapping("/{userId}")
@PreAuthorize("hasRole('ADMIN') ")
public void deleteUser(@PathVariable UUID userId) {
userService.deleteUser(userId);
}

@PatchMapping("/{username}")
@PreAuthorize("hasRole('USER') and #username == authentication.name")
public void updateUserPassword(@PathVariable String username, @RequestBody UserPasswordDto passwordDto) {
userService.updateUserPassword(username, passwordDto);
}
  1. Any user having correct username and password can request login.
  2. Only Admin can register or delete a user.
  3. Any logged in user can invoke /me endpoint can view his profile.
  4. Admin can view any profile by invoking /profile/{username} endpoint. provided user name must match with JWT token.
  5. Only user can update his password

Following is the service class

public class UserService {
private final UserRepository userRepository;

private final JWTUtil jwtUtil;

private final PasswordUtil passwordUtil;

public Users registerUser(UserRegisterDto registerDto) {
if (userRepository.findByUsername(registerDto.getUsername())
.isPresent()) {
throw new BadRequestException("Username already exists");
}

Users user = new Users();
BeanUtils.copyProperties(registerDto, user);
user.setPasswordHash(passwordUtil.getHashedPassword(registerDto.getPassword()));

return userRepository.save(user);
}

public LoginResponse loginUser(UserLoginDto loginDto) {
log.info("Login request for user {}", loginDto.username());

Users user = userRepository.findByUsername(loginDto.username())
.orElseThrow(() -> new UnAuthorizedException("Username does not exists"));

if (!passwordUtil.matchPasswords(loginDto.password(), user.getPasswordHash())) {
throw new UnAuthorizedException("Password does not match.");
}

String token = jwtUtil.generateToken(user);
return LoginResponse.builder()
.token(token)
.build();
}

public Users me() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();

return userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new UnAuthorizedException("User not found"));
}

public Users userProfile(String username) {
return userRepository.findByUsername(username).orElseThrow(() -> new UnAuthorizedException("User not found"));
}

public void deleteUser(UUID userId) {
userRepository.deleteById(userId);
}

public void updateUserPassword(String username, UserPasswordDto passwordDto) {
Users user = userRepository.findByUsername(username)
.orElseThrow(()->new UnAuthorizedException("User not found"));

if (!passwordUtil.matchPasswords(passwordDto.getPassword(), user.getPasswordHash())) {
throw new UnAuthorizedException("Current password does not match.");
}
String hashedPassword = passwordUtil.getHashedPassword(passwordDto.getPassword());
if (passwordUtil.matchPasswords(user.getPasswordHash(), hashedPassword)) {
throw new UnAuthorizedException("New password cannot be the same as the current password.");
}
user.setPasswordHash(hashedPassword);
userRepository.save(user);
}
}
public class PasswordUtil {
private final PasswordEncoder passwordEncoder;

public String getHashedPassword(String password) {
return passwordEncoder.encode(password);
}

public boolean matchPasswords(String password, String hashedPassword){
return passwordEncoder.matches(password, hashedPassword);
}
}

Other code like entity, dtos, repositories can be found in this github repo simplest-jwt-demo.

For DB, I am using default postgres docker compose file provided by spring docker support but you can replace it with any DB endpoints or more sophisticated compose file as per our choice.
Two test user with role ADMIN and USER will be created whenever you run the app. Code for it is present in main class.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Ashish Kumar
Ashish Kumar

Written by Ashish Kumar

0 Followers

A hand on solution architect with 13+ years of experience in building scalable and reliable software solutions

No responses yet

Write a response