Secure Ktor app with JWT refresh tokens. Refresh token flow.

Image is a featured image for article about Ktor and JWT refresh token flow and consist of Ktor logo in the foreground and a blurred photo of a city at night.

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 the AuthRoute 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:

Share this:

Hi there! 👋

Hi there! 👋

My name is Piotr and I've created Codersee to share my knowledge about Kotlin, Spring Framework, and other related topics through practical, step-by-step guides. Always eager to chat and exchange knowledge.

Related content

One Response

  1. 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!

Leave a Reply

Your email address will not be published. Required fields are marked *

Newsletter

Image presents 3 ebooks with Java, Spring and Kotlin interview questions.

Never miss any important updates from the Kotlin world and get 3 ebooks!

You may opt out any time. Terms of Use and Privacy Policy

To make Codersee work, we log user data. By using our site, you agree to our Privacy Policy and Terms of Use.