We’re back with another part of the series Conquer Authentication with Ktor. Remember where we left off in Part 6? We learned how to implement authentication with JWT but that lingering question remained: What happens once the access token expires? Today, we will address this concern and learn how to easily refresh an expired token in the background, without asking the user to re-authenticate.

This post is part of a hands-on tutorial. Feel free to check out the project from GitHub and follow along.

Table of Contents

Juggling Two Tokens instead of One

To automatically refresh the access token, we need two types of tokens. First, there is the actual short-lived access token. Let’s say it expires in an hour. Next, we need a so called refresh token. It pairs with the access token and has typically a considerably longer life span. In our case, let’s assume that the refresh token lasts a day before it expires.

It’s tempting to model these tokens as two separate data classes, like this.

data class AccessToken(val token: String, val expiry: Instant)
data class RefreshToken(val token: String, val expiry: Instant)

However, this isn’t necessary, since the Ktor’s JWT library automatically verifies the exp claim and throws a JWTVerificationException when the token expires. We will though create a wrapper for the refresh token to simplify the parsing process later on.

import kotlinx.serialization.Serializable

@Serializable
data class RefreshToken(val token: String)

The tokens are generated the exact same way. The only difference is their lifespan, which is set as a configuration parameter in application.conf.

auth {
  jwt {
    expirationSeconds {
      accessToken = 3600    // 1 hour
      refreshToken = 86400  // 1 day
    }
  }
}

The function that generates the respective token makes use of the jwtConfig object loaded from the application.conf and in our case it is defined as an extension method in JWTConfig.kt.

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import java.time.Clock

fun JWTConfig.createToken(clock: Clock, user: User, expirationSeconds: Long): String =
    JWT.create()
        .withAudience(this.audience)
        .withIssuer(this.issuer)
        .withClaim("name", user.name)
        .withClaim("role", user.role)
        .withExpiresAt(clock.instant().plusSeconds(expirationSeconds))
        .sign(Algorithm.HMAC256(this.secret))

Finally, we put the pair of tokens in the response to a successful login – see Routing.kt for full implementation details.

// Simplify the call to create a new token
fun createToken(expirationSeconds: Long): String =
  jwtConfig.createToken(clock, user, expirationSeconds)

// Create a pair of tokens
val accessToken = createToken(jwtConfig.expirationSeconds.accessToken)
val refreshToken = createToken(jwtConfig.expirationSeconds.refreshToken)

// Return the pair in the response (provided the user authentication was successful)
call.respond(
  mapOf(
    "accessToken" to accessToken,
    "refreshToken" to refreshToken
  )
)

Once the client receives the response, it should save both the access and the refresh token. From now on, the client will use the access token to access protected resources. When the server eventually rejects the client request due to an expired access token, the client will use the stored refresh token to obtain a new pair of tokens.

Refreshing an Expired Token

In order to refresh an expired access token, the client sends a request to a specific endpoint provided by the server. The details may vary depending on the situation, but in our example, we simply expose a POST /refresh endpoint on our server.

The endpoint is defined in Routing.kt.

import kotlinx.serialization.Serializable

post("/refresh") {
  // Extract the refresh token from the request
  val refreshToken = call.receive<RefreshToken>()

  // Verify the refresh token and obtain the user
  val user = jwtConfig.verify(refreshToken.token) ?: run {
    call.respond(HttpStatusCode.Forbidden, "Invalid refresh token")
    return@post
  }

  // Create new access and refresh tokens for the user
  val newAccessToken = jwtConfig.createToken(
    clock, user, jwtConfig.expirationSeconds.accessToken
  )
  val newRefreshToken = jwtConfig.createToken(
    clock, user, jwtConfig.expirationSeconds.refreshToken
  )
            
  // Respond with the new tokens
  call.respond(
    mapOf(
      "accessToken" to newAccessToken,
      "refreshToken" to newRefreshToken
    )
  )
}

@Serializable
data class RefreshToken(val token: String)

As soon as the client receives a new pair of tokens, it should discard the old pair and replace it with the new one. The client continues using the new access token until the server eventually rejects the client request due to the access token’s expiry. At that point, the client calls the /refresh endpoint using the renewed refresh token, and the whole cycle repeats.

Summary

Thanks for reading, hope you found the post useful. Today we looked at improving the user experience by implementing a token refresh in the background. We tapped into the Ktor’s JWT library to do most of the work for us. Hope you appreciate we only had to write minimum of custom code to achieve what we needed.

Thanks for reading! Today, we explored how to enhance user experience by implementing a token refresh in the background. We tapped into Ktor’s JWT library, which significantly simplified the entire implementation. I hope you found this valuable, especially how we achieved our goals with a minimal amount of custom code. Stay tuned for our next post where we will level up our security measures with OAuth.


Tomas Zezula

Hello! I'm a technology enthusiast with a knack for solving problems and a passion for making complex concepts accessible. My journey spans across software development, project management, and technical writing. I specialise in transforming rough sketches of ideas to fully launched products, all the while breaking down complex processes into understandable language. I believe a well-designed software development process is key to driving business growth. My focus as a leader and technical writer aims to bridge the tech-business divide, ensuring that intricate concepts are available and understandable to all. As a consultant, I'm eager to bring my versatile skills and extensive experience to help businesses navigate their software integration needs. Whether you're seeking bespoke software solutions, well-coordinated product launches, or easily digestible tech content, I'm here to make it happen. Ready to turn your vision into reality? Let's connect and explore the possibilities together.