In the second article of my secure Ktor series, I will show you how to implement a JWT refresh token flow functionality.
To not duplicate ourselves, we will use the project implemented in the previous blog post, in which we learned how to implement a secure REST API with Ktor and authenticate with JWT access tokens. So if you haven’t read that article yet, I highly encourage you to do it now. Nevertheless, if you are interested more in the refresh token part, you can find a link to the GitHub repository and simply clone the project.
Without any further ado, let’s get to work ๐
Video Tutorial
If you prefer video content, then check out my video:
If you find this content useful, please leave a subscription ๐
A Tiny Bit Of Theory
If you’re following me for a longer period, you know that I focus on the practical side of different problems.
Nevertheless, I owe you a bit of explanation before we learn how to secure our Ktor app with JWT refresh token flow. So, in the following paragraphs, I will shortly describe:
- the difference between access tokens and refresh tokens,
- what is a refresh token flow,
- and advantages of such a flow.
Of course, if you feel that’s not necessary in your case, then feel free to skip to the Implement RefreshTokenRepository section ๐
Access Tokens vs Refresh Tokens
Let’s start everything by distinguishing these two.
First of all, they both play distinct roles in token-based authentication systems.
An access token is a short-lived credential that is issued to a client after a successful authentication. Its purpose is to grant the client permission to access protected resources on behalf of the user. And by the client, we mean here a web app, a mobile app, or any other integration working with our REST API.
They are designed to have a limited lifespan to enhance security, reducing the risk associated with compromised tokens.
Refresh tokens, on the other hand, are long-lived credentials that are used to obtain new access tokens without requiring the user to re-authenticate.
While access tokens are meant for short-term authorization, refresh tokens provide a mechanism for obtaining fresh access tokens and extending the user’s session securely.
Refresh Token Flow
To put it simply, when a client authenticates successfully, it receives two tokens: an access token, and a refresh token.
The access token will be used to query our API. If it is compromised, the bad actor will have a limited time to perform unwanted actions on behalf of the user (thanks to its short-lived nature).
The refresh token, with a longer expiration time, will allow the client to get a new access token whenever it expires. This way, the user does not need to specify credentials (like username, and password for instance) every time an access token expires.
With this flow, we can strike a balance between usability and security. We improve user experience and maintain protection against unauthorized access.
Pros vs Cons
As with every solution, refresh token flow has both pros and cons.
When it comes to the advantages, we already covered the first two- the shorter lifespan of access tokens limits the potential impact of a token compromise. Additionally, a long-living refresh token means a better user experience– a longer time between asking the user for his credentials. Lastly, this flow gives us more granular control over token management, for example, we can revoke refresh tokens selectively.
When it comes to the disadvantages, we could call them additional complexity. Not only we must carefully implement on the backend to prevent any vulnerabilities, but also, the storage and transmission of refresh tokens need to be handled securely to prevent unauthorized access.
Lastly, should we always use refresh token flow in our applications?
The answer is- as always- it depends. When dealing with security for our app we should spend some time investigating in depth all options on the table. Such an analysis would be way too much for the article about implementing a Ktor JWT refresh token flow ๐
Implement RefreshTokenRepository
With all of that said, we can finally start the practice part.
As the first step, let’s navigate to the repository
package in our Ktor project and add the RefreshTokenRepository
class:
class RefreshTokenRepository { private val tokens = mutableMapOf<String, String>() fun findUsernameByToken(token: String): String? = tokens[token] fun save(token: String, username: String) { tokens[token] = username } }
Again, in a real-life scenario, this would be the class responsible for communicating to the data source, like a relational database, MongoDB, or any other source used in the project. To keep our tutorial simple and focused on the Ktor/JWT combination, we will use a mutable map storing a combination of tokens with usernames in memory.
As we can see, this class exposes two functions. One is for persisting the data in our map, and the second one is to retrieve usernames based on the JWT refresh token value.
Update JwtService
Nextly, let’s open up the JwtService
class and update it a bit:
import com.auth0.jwt.JWT import com.auth0.jwt.JWTVerifier import com.auth0.jwt.algorithms.Algorithm import com.codersee.model.User import com.codersee.repository.UserRepository import io.ktor.server.application.* import io.ktor.server.auth.jwt.* import java.util.* class JwtService( private val application: Application, private val userRepository: UserRepository, ) { private val secret = getConfigProperty("jwt.secret") private val issuer = getConfigProperty("jwt.issuer") private val audience = getConfigProperty("jwt.audience") val realm = getConfigProperty("jwt.realm") val jwtVerifier: JWTVerifier = JWT .require(Algorithm.HMAC256(secret)) .withAudience(audience) .withIssuer(issuer) .build() fun createAccessToken(username: String): String = createJwtToken(username, 3_600_000) fun createRefreshToken(username: String): String = createJwtToken(username, 86_400_000) private fun createJwtToken(username: String, expireIn: Int): String = JWT.create() .withAudience(audience) .withIssuer(issuer) .withClaim("username", username) .withExpiresAt(Date(System.currentTimeMillis() + expireIn)) .sign(Algorithm.HMAC256(secret)) fun customValidator( credential: JWTCredential, ): JWTPrincipal? { val username: String? = extractUsername(credential) val foundUser: User? = username?.let(userRepository::findByUsername) return foundUser?.let { if (audienceMatches(credential)) JWTPrincipal(credential.payload) else null } } private fun audienceMatches( credential: JWTCredential, ): Boolean = credential.payload.audience.contains(audience) fun audienceMatches( audience: String ): Boolean = this.audience == audience private fun getConfigProperty(path: String) = application.environment.config.property(path).getString() private fun extractUsername(credential: JWTCredential): String? = credential.payload.getClaim("username").asString() }
First of all, we replaced the UserService
dependency from the constructor with the UserRepository
. This way, we would avoid a circular dependency between this class and UserService
later.
Then, we extracted the code responsible for generating JWT tokens into a separate function- createJwtToken
– which will be called by the createAccessToken
, and createRefreshToken
. Please note that these two functions declare different expiration times for both token types- 1 hour for the access token and 24 for the refresh token.
But what expiration time should we set in a real-life? Well, again, it depends ๐ And we must consider both security concerns, as well as the storage. Yes, the longer the expiration time, the bigger the amount of refresh tokens stored in our database.
Lastly, we added the audienceMatches
function, which we will use later in the code.
Update Routing Layer
With all of that done, let’s navigate to the routing
package and make the necessary changes too.
Add Request/Response Classes
As the first step, let’s add a class responsible for mapping refresh token call JSON payload:
import kotlinx.serialization.Serializable @Serializable data class RefreshTokenRequest( val token: String, )
Following, let’s implement a class, which will serialize the authentication response:
import kotlinx.serialization.Serializable @Serializable data class AuthResponse( val accessToken: String, val refreshToken: String, )
And finally, the RefreshTokenResponse
:
import kotlinx.serialization.Serializable @Serializable data class RefreshTokenResponse( val token: String, )
Update Routing.kt
As the next step, let’s update the Routing.kt
class:
import com.codersee.service.UserService import io.ktor.server.application.* import io.ktor.server.routing.* fun Application.configureRouting( userService: UserService ) { routing { route("/api/auth") { authRoute(userService) } route("/api/user") { userRoute(userService) } } }
As we can see, we don’t inject the JwtService
anymore. Instead, we invoke the authRoute
with UserService
instance instead.
Edit AuthRoute.kt
With that done, let’s update theAuthRoute
file:import com.codersee.routing.request.LoginRequest import com.codersee.routing.request.RefreshTokenRequest import com.codersee.routing.response.AuthResponse import com.codersee.routing.response.RefreshTokenResponse import com.codersee.service.UserService import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* fun Route.authRoute(userService: UserService) { post { val loginRequest = call.receive<LoginRequest>() val authResponse: AuthResponse? = userService.authenticate(loginRequest) authResponse?.let { call.respond(authResponse) } ?: call.respond( message = HttpStatusCode.Unauthorized ) } post("/refresh") { val request = call.receive<RefreshTokenRequest>() val newAccessToken = userService.refreshToken(token = request.token) newAccessToken?.let { call.respond( RefreshTokenResponse(it) ) } ?: call.respond( message = HttpStatusCode.Unauthorized ) } }
So, instead of dealing with JwtService
, we are going to invoke the UserService
functions instead.
In the first handler for POST /api/auth
calls, we invoke the authenticate
method and if we receive an AuthResponse
, then we will simply return that to the API client with 200 OK
status. Otherwise, we will return 401 Unauthorized
.
Additionally, we add a new handler for POST /api/auth/refresh
calls. Inside it, we read the JSON payload and create an instance of RefreshTokenRequest
. Then, we invoke the refreshToken
method from UserService
(which we are going to implement in a moment). And just like previously, if the service returns a non-null object, then we respond with 200 OK
and with 401 Unauthorized
if that’s not the case.
Update UserService
As the last thing in our Ktor with the JWT refresh token tutorial, we must update the UserService
:
import com.auth0.jwt.interfaces.DecodedJWT import com.codersee.model.User import com.codersee.repository.RefreshTokenRepository import com.codersee.repository.UserRepository import com.codersee.routing.request.LoginRequest import com.codersee.routing.response.AuthResponse import java.util.* class UserService( private val userRepository: UserRepository, private val jwtService: JwtService, private val refreshTokenRepository: RefreshTokenRepository ) { fun findAll(): List<User> = userRepository.findAll() fun findById(id: String): User? = userRepository.findById( id = UUID.fromString(id) ) fun findByUsername(username: String): User? = userRepository.findByUsername(username) fun save(user: User): User? { val foundUser = userRepository.findByUsername(user.username) return if (foundUser == null) { userRepository.save(user) user } else null } fun authenticate(loginRequest: LoginRequest): AuthResponse? { val username = loginRequest.username val foundUser: User? = userRepository.findByUsername(username) return if (foundUser != null && loginRequest.password == foundUser.password) { val accessToken = jwtService.createAccessToken(username) val refreshToken = jwtService.createRefreshToken(username) refreshTokenRepository.save(refreshToken, username) AuthResponse( accessToken = accessToken, refreshToken = refreshToken, ) } else null } fun refreshToken(token: String): String? { val decodedRefreshToken = verifyRefreshToken(token) val persistedUsername = refreshTokenRepository.findUsernameByToken(token) return if (decodedRefreshToken != null && persistedUsername != null) { val foundUser: User? = userRepository.findByUsername(persistedUsername) val usernameFromRefreshToken: String? = decodedRefreshToken.getClaim("username").asString() if (foundUser != null && usernameFromRefreshToken == foundUser.username) jwtService.createAccessToken(persistedUsername) else null } else null } private fun verifyRefreshToken(token: String): DecodedJWT? { val decodedJwt: DecodedJWT? = getDecodedJwt(token) return decodedJwt?.let { val audienceMatches = jwtService.audienceMatches(it.audience.first()) if (audienceMatches) decodedJwt else null } } private fun getDecodedJwt(token: String): DecodedJWT? = try { jwtService.jwtVerifier.verify(token) } catch (ex: Exception) { null } }
And let’s see the most important changes here.
Firstly, we add the authenticate
function:
fun authenticate(loginRequest: LoginRequest): AuthResponse? { val username = loginRequest.username val foundUser: User? = userRepository.findByUsername(username) return if (foundUser != null && loginRequest.password == foundUser.password) { val accessToken = jwtService.createAccessToken(username) val refreshToken = jwtService.createRefreshToken(username) refreshTokenRepository.save(refreshToken, username) AuthResponse( accessToken = accessToken, refreshToken = refreshToken, ) } else null }
This one is simply responsible for fetching a user from our repository using the passed username
.
If we find it successfully, we check if the password matches (again, it’s just a dummy plain text comparison and you want to learn more about password checks in real-life scenarios). And if that’s the case, then we simply return the access and refresh tokens to the user and persist the refresh token in our “database”.
Nextly, we have a refresh token flow function:
fun refreshToken(token: String): String? { val decodedRefreshToken = verifyRefreshToken(token) val persistedUsername = refreshTokenRepository.findUsernameByToken(token) return if (decodedRefreshToken != null && persistedUsername != null) { val foundUser: User? = userRepository.findByUsername(persistedUsername) val usernameFromRefreshToken: String? = decodedRefreshToken.getClaim("username").asString() if (foundUser != null && usernameFromRefreshToken == foundUser.username) jwtService.createAccessToken(persistedUsername) else null } else null }
When it comes to refreshing a token, we check if there is any persisted username found by the token value. If it is, then we fetch the user by the given username (this way, we won’t refresh a token for user that does not exist anymore).
And at this point, we can rerun our application and verify if our logic works, as expected. Again, if you’d like to get a ready-to-go Postman collection, then please let me know in the comments section.
Ktor With JWT Refresh Token Summary
And basically, that’s all for this, second tutorial in a series related to Ktor security, in which we learned how to create a Ktor application with JWT refresh token flow.
As always, you can find the whole source code in my GitHub repository.
Thank you for being here and please do not forget to share it with other people on your social media (it will help me a lot) and check other posts from the series:
2 Responses
Great post! I’m loving the detailed explanation of the refresh token flow for Secure Ktor apps with JWT. It’s so important to prioritize security in applications, and this post provides valuable insights into how to do just that. Thanks for sharing your expertise!
One question, how do you prevent the client from using the refresh token to access protected resources if you create it the same way that you do for the access token? shouldn’t the refresh token work only for refreshing?