1. Overview
JWT (JSON Web Token) is a compact, URL-safe token format that securely transmits information between parties for authentication and authorization.
In this tutorial, we’ll see a Spring Boot application example with tests to use JWT and secured endpoints.
This article is based on a previous post by Codersee: Spring Boot 3 (Spring Security 6) with Kotlin & JWT.
2. Why Use JWT
Let’s quickly talk about JWT. It will help us to understand specific details of implementations later on.
2.1. JWT in Short
JWT provides a stateless and self-contained means of authorization that includes all necessary information (claims) within a token. It allows fast verification without needing a database lookup, making it efficient for high-traffic applications. JWTs can include expiration control to enhance security, allowing automatic token expiry.
2.2. Access and Refresh Token
We typically have an access and refresh token. A token is part of the Authorization header of HTTP requests.
- Access Token is a short-lived token used to authenticate and authorize access to protected resources or APIs.
- Refresh Token is a long-lived token used to obtain a new access token after the current one expires without requiring the user to re-authenticate. It is usually stored securely and is exchanged for a new access token through an endpoint provided by the authentication server. When the access token expires, the client should catch the error (typically a 401 Unauthorized) and use the refresh token to request a new access token from the server.
2.3. JWT Security Issues
While JWTs are widely used for authentication, they come with potential security risks if not implemented properly.
Common issues include token tampering if the signature isn’t verified correctly, leakage of tokens if they are stored insecurely (such as in local storage or non-HTTPS cookies), and token expiration vulnerabilities, where long-lived tokens like refresh tokens can be misused if compromised.
Using strong algorithms is crucial, as well as setting short expiration times for access tokens and ensuring that tokens are stored and transmitted securely. We might also look at more advanced features of JWT, such as token blacklisting, where compromised or expired tokens are added to a denylist to prevent further access, and token rotation, which enhances security by issuing a new refresh token upon each refresh, invalidating the old one.
3. Application Setup
Let’s build a Spring Boot application to create JWT tokens and access secured endpoints.
3.1. Dependencies and Main Class
First, we need to add some dependencies to our pom.xml. For Kotlin, we need kotlin-reflect and kotlin-stdlib for language features and runtime support:
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>1.9.25</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>1.9.25</version>
</dependency>
Then, we need Spring Boot dependencies for spring-boot-starter-web and spring-boot-starter-security:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.3.0</version>
</dependency>
We also need jackson-module-kotlin for JSON serialization:
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
<version>2.17.2</version>
</dependency>
Finally, we need a main class for which we enable the Spring Security:
@EnableWebSecurity
@SpringBootApplication
class SpringSecurityJwtApplication
fun main(args: Array<String>) {
runApplication<JwtApplication>(*args)
}
3.2. User
Some users will have access to the application:
data class User(
val id: UUID,
val name: String,
val password: String,
val role: Role
)
enum class Role {
USER, ADMIN
}
To save time here, we won’t go through the user creation process. Therefore, we provide a repository with some hard-coded users:
@Repository
class UserRepository(
encoder: PasswordEncoder
) {
private val users = mutableSetOf(
User(
id = UUID.randomUUID(),
name = "[email protected]",
password = encoder.encode("pass1"),
role = Role.USER,
),
User(
id = UUID.randomUUID(),
name = "[email protected]",
password = encoder.encode("pass2"),
role = Role.ADMIN,
),
User(
id = UUID.randomUUID(),
name = "[email protected]",
password = encoder.encode("pass3"),
role = Role.USER,
)
)
fun findByUsername(email: String): User? =
users
.firstOrNull { it.name == email }
}
3.3. Secured Endpoint
We also need a simple endpoint to test later on:
@RestController
@RequestMapping("/api")
class HelloController {
@GetMapping("/hello")
fun hello(): ResponseEntity<String> {
return ResponseEntity.ok("Hello, Authorized User!")
}
}
3.4. UserDetailService
Finally, let’s create a UserDetailService we’ll add later in our security configuration to authenticate the user:
class JwtUserDetailsService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val user = userRepository.findByUsername(username)
?: throw UsernameNotFoundException("User $username not found!")
return User.builder()
.username(user.name)
.password(user.password)
.roles(user.role.name)
.build()
}
}
The UserDetailService is the core of authentication that verifies the user’s existence in the repository.
4. JWT and Authorization Filter
To use a token in our application, we need an authorization filter where we can parse our requests. The token is part of a request header in the form of:
Authorization: Bearer <JWT token>
If the user has authentication but no token yet, we need to create a JWT token and add it to the request. The client will then be able to access secured endpoints.
4.1. JJWT and Token Service
We can create a service to generate a JWT and extract the claims when necessary. We’ll use JJWT and need to add some dependencies to our project. First, let’s define the JWT version:
<properties>
<jwt.version>0.11.5</jwt.version>
</properties>
Then, we need jjtw-api and jjwt-impl:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
We also need jjwt-jackson for JSON serialization:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
Let’s see how we can implement a TokenService. For this tutorial, we’ll use the HmacSHA256 encryption:
@Service
class TokenService(
@Value("\${jwt.secret}") private val secret: String = ""
) {
private val signingKey: SecretKeySpec
get() {
val keyBytes: ByteArray = Base64.getDecoder().decode(secret)
return SecretKeySpec(keyBytes, 0, keyBytes.size, "HmacSHA256")
}
fun generateToken(subject: String, expiration: Date, additionalClaims: Map<String, Any> = emptyMap()): String {
return Jwts.builder()
.setClaims(additionalClaims)
.setSubject(subject)
.setIssuedAt(Date(System.currentTimeMillis()))
.setExpiration(expiration)
.signWith(signingKey)
.compact()
}
fun extractUsername(token: String): String {
return extractAllClaims(token).subject
}
private fun extractAllClaims(token: String): Claims {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.body
}
}
Next, we need a secret key to code and decode our token — we can set this secret key as a system property. We’ll access the properties for testing and production using @Value. We can add any info we need in the claims of the token.
4.2. Authentication Service
Once we can generate and extract info from a token, we can create another service to authenticate and generate tokens. Given that we’re creating access and refresh tokens, we’ll also need a repository to keep track of the generated refresh token.
Therefore, let’s add a RefreshTokenRepository:
@Repository
class RefreshTokenRepository {
private val tokens = mutableMapOf<String, UserDetails>()
fun findUserDetailsByToken(token: String): UserDetails? =
tokens[token]
fun save(token: String, userDetails: UserDetails) {
tokens[token] = userDetails
}
}
Let’s now look at the AuthenticationService:
@Service
class AuthenticationService(
private val authManager: AuthenticationManager,
private val userDetailsService: UserDetailsService,
private val tokenService: TokenService,
private val refreshTokenRepository: RefreshTokenRepository,
@Value("\${jwt.accessTokenExpiration}") private val accessTokenExpiration: Long = 0,
@Value("\${jwt.refreshTokenExpiration}") private val refreshTokenExpiration: Long = 0
) {
fun authentication(authenticationRequest: AuthenticationRequest): AuthenticationResponse {
authManager.authenticate(
UsernamePasswordAuthenticationToken(
authenticationRequest.username,
authenticationRequest.password
)
)
val user = userDetailsService.loadUserByUsername(authenticationRequest.username)
val accessToken = createAccessToken(user)
val refreshToken = createRefreshToken(user)
refreshTokenRepository.save(refreshToken, user)
return AuthenticationResponse(
accessToken = accessToken,
refreshToken = refreshToken
)
}
fun refreshAccessToken(refreshToken: String): String {
val username = tokenService.extractUsername(refreshToken)
return username.let { user ->
val currentUserDetails = userDetailsService.loadUserByUsername(user)
val refreshTokenUserDetails = refreshTokenRepository.findUserDetailsByToken(refreshToken)
if (currentUserDetails.username == refreshTokenUserDetails?.username)
createAccessToken(currentUserDetails)
else
throw AuthenticationServiceException("Invalid refresh token")
}
}
private fun createAccessToken(user: UserDetails): String {
return tokenService.generateToken(
subject = user.username,
expiration = Date(System.currentTimeMillis() + accessTokenExpiration)
)
}
private fun createRefreshToken(user: UserDetails) = tokenService.generateToken(
subject = user.username,
expiration = Date(System.currentTimeMillis() + refreshTokenExpiration)
)
}
Once the user is authenticated, the authentication() method exposes access and refresh tokens to the client. The refresh token is saved for later use when, in the refreshAccessToken() method, we create a new access token for the client. Notably, before the token is created, we check that the user’s token matches the request’s user to avoid token hijacking.
Finally, let’s create the endpoints for the authentication:
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authenticationService: AuthenticationService
) {
@PostMapping
fun authenticate(
@RequestBody authRequest: AuthenticationRequest
): AuthenticationResponse =
authenticationService.authentication(authRequest)
@PostMapping("/refresh")
fun refreshAccessToken(
@RequestBody request: RefreshTokenRequest
): TokenResponse = TokenResponse(token = authenticationService.refreshAccessToken(request.token))
}
The /api/auth endpoint is for the client to get access and refresh tokens by providing the credentials the first time. The /refresh endpoint is a distinct endpoint for refreshing tokens to prevent unnecessary re-authentication.
4.4. Authorization Filter
The last bit we need is the authorization filter:
@Component
class JwtAuthorizationFilter(
private val userDetailsService: UserDetailsService,
private val tokenService: TokenService
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authorizationHeader: String? = request.getHeader("Authorization")
if (null != authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
try {
val token: String = authorizationHeader.substringAfter("Bearer ")
val username: String = tokenService.extractUsername(token)
if (SecurityContextHolder.getContext().authentication == null) {
val userDetails: UserDetails = userDetailsService.loadUserByUsername(username)
if (username == userDetails.username) {
val authToken = UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.authorities
)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.getContext().authentication = authToken
}
}
} catch (ex: Exception) {
response.writer.write(
"""{"error": "Filter Authorization error:
|${ex.message ?: "unknown error"}"}""".trimMargin()
)
}
}
filterChain.doFilter(request, response)
}
}
The steps for the authorization are:
- If the header contains the Bearer part, we extract the token with the extractUsername() method
- JWT is stateless, so we have no information about the session. If this is the case, we check that the requesting user is correct and set the token in the context to confirm the authorization.
- When no authorization is granted or any exception is raised, like a token expiration, the filter chain will continue without authorization in the request, leading to a forbidden response (403 status code). Otherwise, the request will have access to the next layers of the application.
5. Security Configuration
We’ve seen the main components of our application. Now, let’s add the security configuration so that we’re set to go:
@Configuration
class SecurityConfig {
@Bean
fun userDetailsService(userRepository: UserRepository): UserDetailsService =
JwtUserDetailsService(userRepository)
@Bean
fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager =
config.authenticationManager
@Bean
fun authenticationProvider(userRepository: UserRepository): AuthenticationProvider =
DaoAuthenticationProvider()
.also {
it.setUserDetailsService(userDetailsService(userRepository))
it.setPasswordEncoder(encoder())
}
@Bean
fun securityFilterChain(
http: HttpSecurity,
jwtAuthenticationFilter: JwtAuthorizationFilter,
authenticationProvider: AuthenticationProvider
): DefaultSecurityFilterChain {
http
.csrf { it.disable() }
.authorizeHttpRequests {
it
.requestMatchers("/api/auth", "/api/auth/refresh", "/error")
.permitAll()
.anyRequest()
.fullyAuthenticated()
}
.sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
@Bean
fun encoder(): PasswordEncoder = BCryptPasswordEncoder()
}
It’s a classic Spring Security configuration. The key change is adding the authorization filter:
addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
Notably, we permit everyone to use the /auth endpoint to generate the tokens.
6. Tests
Let’s test our application using MVC testing. We need to add testing dependencies to our project for spring-boot-starter-test and spring-security-test:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
version>3.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>6.3.0</version>
</dependency>
Let’s look at our test class. To create edge cases like token expiration, we need to mock some beans using @SpyBean:
@SpringBootTest(classes = [JwtApplication::class])
@AutoConfigureMockMvc
@TestPropertySource("classpath:application-test.properties")
class JwtApplicationIntegrationTest {
@Value("\${jwt.expiredToken}")
private lateinit var oldToken: String
@Autowired
private lateinit var mockMvc: MockMvc
@SpyBean
private lateinit var tokenService: TokenService
@SpyBean
private lateinit var userDetailsService: UserDetailsService
// tests
}
We also need some properties to generate the tokens:
jwt.secret=25cf3c44c8f39313e8cbf7c23e22fe8b2ee8b288ee5206b0a6397583a1f7f0ef
jwt.accessTokenExpiration=60000
jwt.refreshTokenExpiration=360000
jwt.expiredToken=eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJlbWFpbC0xQGdtYWlsLmNvbSIsImlhdCI6MTcyNzQ1Mjk2MCwiZXhwIjoxNzI3NDU2NTYwfQ.oP0dNWn75v8Ka7fvxt-966ug2q3A5i4Ef-urjo0bQtSCZeq9f4ijA7HydBC-xMX2
6.1. Happy Path
Let’s start with the most common case where we get the tokens, make the token expire, and get a new token to re-access the test endpoint:
@Test
fun `access secured endpoint with new token from the refresh token after token expiration`() {
val authRequest = AuthenticationRequest("[email protected]", "pass1")
var jsonRequest = jacksonObjectMapper().writeValueAsString(authRequest)
var response = mockMvc.perform(
post("/api/auth")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.accessToken").isNotEmpty)
.andExpect(jsonPath("$.refreshToken").isNotEmpty).andReturn().response.contentAsString
val authResponse = jacksonObjectMapper().readValue(response, AuthenticationResponse::class.java)
// access secured endpoint
mockMvc.perform(
get("/api/hello")
.header("Authorization", "Bearer ${authResponse.accessToken}")
)
.andExpect(status().isOk)
.andExpect(content().string("Hello, Authorized User!"))
// simulate access token expiration
`when`(tokenService.extractUsername(authResponse.accessToken))
.thenThrow(ExpiredJwtException::class.java)
mockMvc.perform(
get("/api/hello")
.header("Authorization", "Bearer ${authResponse.accessToken}")
)
.andExpect(status().isForbidden)
// create a new access token from the refresh token
val refreshTokenRequest = RefreshTokenRequest(authResponse.refreshToken)
jsonRequest = jacksonObjectMapper().writeValueAsString(refreshTokenRequest)
response = mockMvc.perform(
post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.token").isNotEmpty).andReturn().response.contentAsString
val newAccessToken = jacksonObjectMapper().readValue(response, TokenResponse::class.java)
reset(tokenService)
// access secured endpoint with the new token
mockMvc.perform(
get("/api/hello")
.header("Authorization", "Bearer ${newAccessToken.token}")
)
.andExpect(status().isOk)
.andExpect(content().string("Hello, Authorized User!"))
}
We get access twice to the /hello endpoint — before and after we request a new token to the /refresh endpoint, given our access token has expired.
If we want to see what the JWT looks like, we can debug with an IDE and inspect the token:
{
"header" : {
"alg" : "HS384"
},
"payload" : {
"sub" : "[email protected]",
"iat" : 1727554174,
"exp" : 1727554774
},
"signature" : "MzkwqM5FVJMCTKXgVVR9HKpyvt5_4MGwq-VsWKaNdh733UKSy4AOhffAXZsFmUnk"
}
The token contains a lot of information, such as the encryption algorithm and the signature used to validate the key.
6.2. Unhappy Path
We also want to test a few error cases. Let’s start with an unauthenticated user:
@Test
fun `should return unauthorized for unauthenticated user`() {
val authRequest = AuthenticationRequest("some-user", "pass1")
val jsonRequest = jacksonObjectMapper().writeValueAsString(authRequest)
mockMvc.perform(
post("/api/auth")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest)
)
.andExpect(status().isUnauthorized)
}
This can also happen in the case of an expired refresh token:
@Test
fun `refresh token with invalid refresh token should return unauthorized`() {
val jsonRequest = jacksonObjectMapper().writeValueAsString(
RefreshTokenRequest(
expiredToken
)
)
mockMvc.perform(
post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest)
)
.andExpect(status().isUnauthorized)
}
In this case, the client will need to get authentication again, like logging into the application.
There might also be the case of someone stealing our token and trying to use it to get access, both for the /hello or /refresh endpoints:
Test
fun `should return forbidden for tampered refresh token`() {
val authRequest = AuthenticationRequest("[email protected]", "pass1")
var jsonRequest = jacksonObjectMapper().writeValueAsString(authRequest)
val response = mockMvc.perform(
post("/api/auth")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.accessToken").isNotEmpty)
.andExpect(jsonPath("$.refreshToken").isNotEmpty).andReturn().response.contentAsString
val authResponse = jacksonObjectMapper().readValue(response, AuthenticationResponse::class.java)
val refreshTokenRequest = RefreshTokenRequest(authResponse.refreshToken)
jsonRequest = jacksonObjectMapper().writeValueAsString(refreshTokenRequest)
`when`(userDetailsService.loadUserByUsername("[email protected]"))
.thenReturn(User("[email protected]", "pass2", ArrayList()))
mockMvc.perform(
post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest)
)
.andExpect(status().isUnauthorized)
}
@Test
fun `should return forbidden for tampered token`() {
val authRequest = AuthenticationRequest("[email protected]", "pass1")
val jsonRequest = jacksonObjectMapper().writeValueAsString(authRequest)
val response = mockMvc.perform(
post("/api/auth")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.accessToken").isNotEmpty)
.andExpect(jsonPath("$.refreshToken").isNotEmpty).andReturn().response.contentAsString
val authResponse = jacksonObjectMapper().readValue(response, AuthenticationResponse::class.java)
`when`(userDetailsService.loadUserByUsername("[email protected]"))
.thenReturn(User("[email protected]", "pass2", ArrayList()))
mockMvc.perform(
get("/api/hello")
.header("Authorization", "Bearer ${authResponse.accessToken}")
)
.andExpect(status().isForbidden)
}
We need both authentication and authorization to access an endpoint; therefore, given only the token, we shouldn’t be able to get access.
7. Conclusion
In this tutorial, we saw how to build a Spring Boot application that uses JWT, how JWT works, and the difference between access and refresh tokens.
We built the authentication service and the authorization filter to parse and validate the Bearer token in the request header. We also added some tests to access secured endpoints with a valid access token as well as edge cases like token expiration or tampering.