Why?
This is yet-another-spring-boot-jwt-tutorial. It has 2 main motives:
- To understand and document my understanding about spring security.
- The existing articles mostly use jjwt which has a vulnerable release (as of now). Thus, I decided to use an alternative library, along with springdoc openapi 3 swagger docs.
Probably you could scavenge the internet, and use LLMs to put up such an implementation. I just created a cookbook kind of, you may use it as a guide.
Let’s start…
How?
This is not at all a beginner’s guide, and I would assume the reader knows the basics of spring, IOC container, gradle, spring data JPA, how to build basic CRUD in spring boot.
As a starting point, you may follow this guide .
Initialize
This is a simple CRUD application with just 3 routes: /register
, /login
and /profile
(protected route). I hope this is pretty much self-explanatory. We will be using the H2 database
, which is an in-memory database. It is easy to deal with during development, but not ideal for production. There’s also an inbuilt web UI for H2 database.
Go to Spring boot initializr .
You will need the following dependencies:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.auth0:java-jwt:4.4.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
The above alien language is groovy, which is used to configure Gradle build system (similar to xml in maven). The dependencies block is straightforward and pretty self-explanatory (one of the reasons why I prefer Gradle).
Now, extract and import the gradle/maven project into your preferred editor. If any error occurs, make sure about spring and Java version.
Two dependencies (com.auth0:java-jwt
and org.springdoc:springdoc-openapi-starter-webmvc-ui
) aren’t available in spring starter.
Get them from here
and here
.
Model
We will first create our model, then controller, then service, in the meantime, we will also learn about the required things as we go along…
Create a package called
controller
.Create
UserDao.java
:package org.devabir.jwtexample.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.util.Date; @Entity @Table(name = "user_") @Data @AllArgsConstructor @NoArgsConstructor public class UserDao { @Id private String email; @Column(nullable = false, name = "hashed_password") private String hashedPassword; @CreationTimestamp @Column(name = "created_at", updatable = false, nullable = false) private final Date createdAt = new Date(); @UpdateTimestamp @Column(name = "updated_at") private Date updatedAt; }
This is for interacting with the database. Most db has
user
as reserved keyword, hence, we are calling ituser_
.Getters, setters, and constructors are all auto-generated by the annotations from lombok. If you are new to this, you should spend some time and setup lombok in your ide .
Create
UserRequestDto.java
:package org.devabir.jwtexample.model; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class UserRequestDto { @NotBlank(message = "Email is mandatory.") // This email regex works. Trust me :) @Email(message = "Please enter a valid email.", regexp = "^[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$") private String email; @NotBlank(message = "Password is mandatory.") private String password; }
It also has validation logic. The email regex is sourced from some online searches, and it will work every time (trust me :)).
Create
UserResponseDto.java
:package org.devabir.jwtexample.model; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; @Data @AllArgsConstructor @NoArgsConstructor public class UserResponseDto { private String email; private Date createdAt; private Date updatedAt; }
Separate Response object, so that we don’t accidentally leak out confidential attributes like password, also it includes generated fields like created and updated timestamp.
Create
TokenResponse.java
:package org.devabir.jwtexample.model; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class TokenResponse { private String accessToken; private UserResponseDto user; }
This class will be specifically used to respond on a
/login
request. We will not only send the user info, but also the JWT access token. In later sections, we will see how to set up the JWT part.
This is all about the models, now let’s set up the repository for interacting with the database.
JPA Repository
Create a package called repository
, then inside that create UserRepository.java
:
package org.devabir.jwtexample.repository;
import org.devabir.jwtexample.model.UserDao;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<UserDao, String> {
}
Controller
We have only one controller. Create a package controller
, inside that a file AuthController.java
:
package org.devabir.jwtexample.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import org.devabir.jwtexample.model.TokenResponse;
import org.devabir.jwtexample.model.UserRequestDto;
import org.devabir.jwtexample.model.UserResponseDto;
import org.devabir.jwtexample.service.AuthService;
import org.devabir.jwtexample.service.JwtService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
public AuthController(AuthService authService, JwtService jwtService) {
this.authService = authService;
this.jwtService = jwtService;
}
@PostMapping("/register")
public ResponseEntity<UserResponseDto> register(@Valid @RequestBody UserRequestDto userRequestDto) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(this.authService.register(userRequestDto));
}
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody UserRequestDto userRequestDto) {
UserResponseDto user = authService.login(userRequestDto);
final String accessToken = jwtService.buildToken(user.getEmail());
return ResponseEntity.ok(new TokenResponse(accessToken, user));
}
@Operation(security = {@SecurityRequirement(name = "bearer-key")})
@GetMapping("/profile")
public ResponseEntity<UserResponseDto> profile() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
final String email = userDetails.getUsername();
return ResponseEntity.ok(this.authService.profile(email));
}
}
- This controller has 2 dependencies,
AuthService
andJwtService
. We will eventually implement them. - This controller will give us a high-level overview of the API we are going to create.
@Valid
annotation will validate the Bean with the constraints like@NotBlank
, defined earlier, and throw an appropriate error. Spring’s default exception handler ProblemDetailsExceptionHandler , will respond with an appropriate JSON.@Operation(security = {@SecurityRequirement(name = "bearer-key")})
annotation is specific to springdoc, we will come to it at last. Just remember that it helps us to identify which routes require authentication in the generated docs.- In English, this roughly translates to: “If the user is authenticated, Spring should already have the authenticated user’s data, which we are simply accessing.”:Keep this part in mind, we will again use these when setting up security configs.
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); UserDetails userDetails = (UserDetails) authentication.getPrincipal(); final String email = userDetails.getUsername();
Service
Create a package called
service
.Create
UserService.java
:package org.devabir.jwtexample.service; import org.devabir.jwtexample.model.UserDao; import org.devabir.jwtexample.model.UserResponseDto; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; @Service public class UserService { public UserResponseDto toDto(UserDao userDao) { UserResponseDto result = new UserResponseDto(); BeanUtils.copyProperties(userDao, result, "hashedPassword"); return result; } }
- This file only converts the DAO to DTO, thus adhering to separation-of-concerns.
- DTO and DAO have many common fields, thus we are copying the Bean properties. There are other methods like model-mapper, builder or constructor from lombok. This seemed short and simple enough.This will work, even if you just omit the last parameter, I just included that to be explicit.
public static void copyProperties( Object source, Object target, String... ignoreProperties) throws BeansException {}
Create
JwtService.java
:package org.devabir.jwtexample.service; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.JWTVerifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.Date; @Service public class JwtService { private final long jwtExpiration; private final Algorithm signingAlgorithm; public JwtService( @Value("${security.jwt.secret-key}") String secretKey, @Value("${security.jwt.expiration-time}") long jwtExpiration ) { this.jwtExpiration = jwtExpiration; this.signingAlgorithm = Algorithm.HMAC256(secretKey); } public String extractEmail(String token) { JWTVerifier jwtVerifier = JWT.require(signingAlgorithm).build(); DecodedJWT jwt = jwtVerifier.verify(token); return jwt.getSubject(); } public boolean isTokenValid(String token, String email) { try { JWTVerifier verifier = JWT.require(signingAlgorithm) .withSubject(email) .build(); verifier.verify(token); } catch (JWTVerificationException exception) { return false; } return true; } public String buildToken(String email) { return JWT.create() .withSubject(email) .withIssuedAt(new Date()) .withExpiresAt(new Date(System.currentTimeMillis() + jwtExpiration)) .sign(signingAlgorithm); } }
- This code actually uses the Auth0’s JWT library .
@Value("${security.jwt.secret-key}")
imports values from theapplication.properties
file.- This is mostly self-explanatory, we are setting user’s email as the subject and signing algorithm as
HMAC256
. - One thing to keep in mind,
jwtVerifier.verify(token);
could throw verification error.
At last
AuthService.java
:package org.devabir.jwtexample.service; import org.devabir.jwtexample.model.UserDao; import org.devabir.jwtexample.model.UserRequestDto; import org.devabir.jwtexample.model.UserResponseDto; import org.devabir.jwtexample.repository.UserRepository; import org.springframework.beans.BeanUtils; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @Service public class AuthService { private final UserService userService; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final AuthenticationManager authenticationManager; public AuthService(UserService userService, UserRepository userRepository, PasswordEncoder passwordEncoder, AuthenticationManager authenticationManager) { this.userService = userService; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.authenticationManager = authenticationManager; } public UserResponseDto register(UserRequestDto userRequestDto) { final String email = userRequestDto.getEmail(); if (this.userRepository.findById(email).isPresent()) throw new ResponseStatusException(HttpStatus.CONFLICT, "Email " + email + " is already taken."); UserDao userDao = new UserDao(); BeanUtils.copyProperties(userRequestDto, userDao, "password"); userDao.setHashedPassword(this.passwordEncoder.encode(userRequestDto.getPassword())); userDao = this.userRepository.save(userDao); return this.userService.toDto(userDao); } public UserResponseDto login(UserRequestDto userRequestDto) { final String email = userRequestDto.getEmail(); UserDao userDao = userRepository. findById(email) .orElseThrow( () -> new UsernameNotFoundException("User " + email + " not found.") ); this.authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( userRequestDto.getEmail(), userRequestDto.getPassword() ) ); return userService.toDto(userDao); } public UserResponseDto profile(String email) { UserDao userDao = userRepository .findById(email) .orElseThrow(() -> new UsernameNotFoundException("User " + email + " not found.")); return userService.toDto(userDao); } }
- We need to define 2 beans (
PasswordEncoder
andAuthenticationManager
). We will define them in upcoming sections. - This will authenticate and save the user data into Spring’s context.
this.authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( userRequestDto.getEmail(), userRequestDto.getPassword() ) );
- We need to define 2 beans (
Config
Create a package called
config
.Create
AppConfig.java
:package org.devabir.jwtexample.config; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.security.SecurityScheme; import org.devabir.jwtexample.model.UserDao; import org.devabir.jwtexample.repository.UserRepository; 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.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.util.Collection; import java.util.List; import java.util.Optional; @Configuration public class AppConfig { private final UserRepository userRepository; public AppConfig(UserRepository userRepository) { this.userRepository = userRepository; } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } @Bean BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService()); authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } @Bean UserDetailsService userDetailsService() { return new UserDetailsService() { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // NOTE: no username, we just use emails internally. final String email = username; Optional<UserDao> optionalUserDao = userRepository.findById(email); if (optionalUserDao.isEmpty()) { throw new UsernameNotFoundException("User " + email + " not found."); } UserDao user = optionalUserDao.get(); return new UserDetails() { @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(); } @Override public String getPassword() { return user.getHashedPassword(); } @Override public String getUsername() { return user.getEmail(); } }; } }; } @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .components( new Components() .addSecuritySchemes( "bearer-key", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT") ) ); } }
- This is probably the most convoluted part of our current app. Here, our business logic interfaces with spring boot framework. We will go from top to bottom.
- The beans for
AuthenticationManager
andPasswordEncoder
were required by theAuthService
, we are defining it here. These will be injected intoAuthService
. - Spring framework can work with many
AuthenticationProvider
, like:DaoAuthenticationProvider
,LdapAuthenticationProvider
. Also, we can implement complex rules like different authentication providers for different routes. - We are defining a
DaoAuthenticationProvider
, which is very common (it uses aUserDetailsService
to retrieve user details from the database and compare credentials). - This
UserDetailsService
is very spring-specific way of defining users. We need to defineloadUserByUsername(...)
to retrieve the user from Database. We can also define some authorities for role-based access control purposes. Here username is just the user’s email. - How we will define the
UserDetails
is up to us and spring will remember that, we can access that in a protected route. customOpenAPI()
this is used to enhance the generated swagger docs, by adding authentication functionality. We will use this in demo later. Source .
Create
SecurityConfig.java
:package org.devabir.jwtexample.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; @Configuration @EnableWebSecurity public class SecurityConfig { private final AuthenticationProvider authenticationProvider; private final JwtAuthFilter jwtAuthFilter; public SecurityConfig(AuthenticationProvider authenticationProvider, JwtAuthFilter jwtAuthFilter) { this.authenticationProvider = authenticationProvider; this.jwtAuthFilter = jwtAuthFilter; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { httpSecurity.csrf(csrf -> csrf.disable()); httpSecurity.headers(h -> h.frameOptions(fo -> fo.disable())); httpSecurity.authorizeHttpRequests( authorizeHttpRequests -> authorizeHttpRequests .requestMatchers("/auth/profile") .authenticated() .anyRequest() .permitAll() ); httpSecurity.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); httpSecurity.authenticationProvider(authenticationProvider); httpSecurity.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return httpSecurity.build(); } @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedOrigins(List.of("http:localhost:8080")); corsConfiguration.setAllowedMethods(List.of("GET", "POST")); corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfiguration); return source; } }
- This uses our defined
AuthenticationProvider
. CorsConfigurationSource
is for setting up CORS configuration. These are set up on the server side. Read this for getting an idea .- Each request at first goes through a number of filters. This is called
SecurityFilterChain
. We are configuring that:- Disable CSRF because it’s a REST API and we aren’t working with session cookies.
- Disable X-Frame-Options, mainly for h2-console web UI. This might not be a good idea in production.
/auth/profile
will be a protected route. The rest are public.- No session cookie, thus
SessionCreationPolicy.STATELESS
. httpSecurity.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
we are adding the JWT filter (to extract email from JWT token), before Spring’s Auth filter.
- This uses our defined
Properties
Finally, this is the last file.
Create application.properties
within resources folder:
spring.application.name=jwtexample
server.error.include-stacktrace=never
server.error.include-exception=false
server.error.include-message=always
spring.datasource.url=jdbc:h2:mem:userdb
spring.datasource.driverClassName=org.h2.Driver
spring.h2.console.enabled=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
security.jwt.secret-key=3e0ba6026587dc722876146dd83b2222
# 1h in millisecond
security.jwt.expiration-time=3600000
spring.output.ansi.enabled=ALWAYS
- The
security.jwt.secret-key
needs to be of 32 chars (HMAC256
requires 256-bit key (32 * 8 = 256 bits)). - The
spring.output.ansi.enabled=ALWAYS
always gives a colored output :). server.error
properties are used to conceal some confidential info. while presenting user with an error response. This is still not full proof and may leak info in validation errors, or sql errors.- We are using the H2 database , which is an in-memory database. It is easy to deal with during development, but not ideal for production. There’s also an inbuilt web UI for H2 database.
Demo
- Run the application in your IDE, or in terminal run
./gradlew bootRun
(gradle),./mvnw spring-boot:run
(maven). - Go to: http://localhost:8080/swagger-ui.html
- Explore the h2 database at: http://localhost:8080/h2-console. Make sure to enter proper database name (
userdb
). - Do register (expand and press on Try it out), then login and at last access the profile. Use padlock sign to copy and paste the JWT access token, you get after logging in.
- Also, check for the validation errors.
- Below are some screenshots:
If you have any issue following, here’s the source code repo .
Thanks a lot for reading
Stay safe,
Have a nice day.