Secure REST API with Ktor and JWT Access Tokens

Image presents a Ktor logo in the foreground and a blurred photo of some library in the background.

Hello and welcome to the first article in a series. This time, I will show you how to implement a simple REST API with Ktor and secure it with JWT (JSON Web Token) access tokens.

To be even more specific, in this tutorial I will teach you:

  • how to implement a simple REST API,
  • what are JSON Web Tokens and what does the authentication mean,
  • how to generate and validate JWT access tokens, and authenticate users,
  • and how to read data using the JWTPrincipal.

In the upcoming articles, we will continue the topic and learn additionally:

Video Tutorial

If you prefer video content, then check out my video:

If you find this content useful, please leave a subscription  😉

What Is Authentication?

But before we start implementing our Ktor app with JWT tokens, let me quickly explain what exactly authentication is.

In essence, it is the process of verifying the identity of a user, system, or device to ensure that they are who they claim to be.

It is like having a secret code to enter a club. Let’s imagine we want to get into a cool party, and at the entrance, there’s a bouncer checking everyone’s membership. To prove we belong, we provide a special password. If the password matches what’s on the list, we’re in!

In the digital world, it’s the same idea – we need to confirm our identity with a unique key (like a password or JWT token) to access our email, bank account, or, like in our case, any resource through the exposed REST API.

Of course, authentication is not a synonym for authorization (but we will get back to that in the upcoming post).

JWT Tokens Explained

As the last thing before we head to the practice, let’s learn a bit about the JWT tokens.

JWTs, also known as JSON Web Tokens, are nothing else than one of plenty of existing ways to authenticate users.

Just like with our previous example- in real life, we can use our ID, passport, or in other cases, some secret word.

Here, the JWT is like a digital secret handshake on the internet. When sign in using our username and password, the server gives us a special JWT, and later, we can use it to access a REST API. This way, we don’t need to specify such vulnerable data every time we want to make a request.

What are the other advantages? Well, they are safe and compact, and allow us to hold information.

JWTs consist of three parts separated by dots: header.payload.signature:

The image is a screenshot from jwt.io page and present and example encoded JWT token with its decoded value.

The encoded value can be easily decoded and JWT token values can be read.

The header typically consists of two parts: the type of the token (missing above), and the signing algorithm being used (HMAC SHA512).

The second part of the token is the payload, which contains the claims. Claims, Claims are statements about an entity (typically, the user) and additional data.

Lastly, the signature is used to verify that the sender of the JWT is who it says it is and to ensure that the message wasn’t changed along the way.

Set Up New Ktor Project

With all of that said, let’s navigate to the Ktor Project Generator and start by specifying the necessary details:

Image is a screenshot from the Ktor Project Generator page and shows the first step of generator settings, like Ktor version, engine set to Netty, or HOCON file config.

At this stage, we must specify the basic things, like the Ktor version, engine, etc.

Nextly, let’s click the “Add plugins” button and configure the following plugins (aka dependencies):

Image shows all added plugins in the configurator: authentication, authentication JWT, content negotiation, kotlinx.serialization, and routing.

As we can see, in order to expose a REST API and secure it with JWT in Ktor, we will need the following:

  • Authentication,
  • Authentication JWT,
  • Content Negotiation,
  • kotlinx.serialization,
  • and Routing.

With that done, let’s generate the project, download the ZIP file, extract it, and import it to the IntelliJ IDEA.

When we open up the project, we should see that there are some plugins already configured inside the plugins package and Application.kt file.

Let’s delete files inside the plugins and clean up the Application.kt to be on the same page:

import io.ktor.server.application.*

fun main(args: Array<String>) {
  io.ktor.server.netty.EngineMain.main(args)
}

fun Application.module() {
}

Expose Users API

As the next step, let’s expose the API responsible for user management.

Add User Model

Firstly, let’s create the model package and implement the User.kt class:

import java.util.UUID

data class User(
  val id: UUID,
  val username: String,
  val password: String
)

As can be seen, it’s just a simple class with the id, username, and password fields.

Create UserRepository

Secondly, we must add a class responsible for user storage and retrieval.

To do so, let’s add the repository package and put the UserRepository inside it:

import com.codersee.model.User
import java.util.*

class UserRepository {

  private val users = mutableListOf<User>()

  fun findAll(): List<User> =
    users

  fun findById(id: UUID): User? =
    users.firstOrNull { it.id == id }

  fun findByUsername(username: String): User? =
    users.firstOrNull { it.username == username }

  fun save(user: User): Boolean =
    users.add(user)
}

Normally, we would put a code responsible for “talking” to the database right here.

In our case, to not lose focus from the clue of this article- authentication in Ktor and JWT- we introduce a dummy repository, which uses the mutable list to store users in memory.

Note: in our dummy repository, we store user password in a plain text.

Nevertheless, in real-life scenarios we should always encrypt it before persisting. To learn a bit more about different encryption algorithms, you can check out my other post in which I explain PBKDF2 hashing.

Implement UserService

With that done, let’s introduce a service layer in our project.

So, similarly, let’s add the service package and the UserService class:

import com.codersee.model.User
import com.codersee.repository.UserRepository
import java.util.*

class UserService(
  private val userRepository: UserRepository
) {

  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
  }
}

This time, we expose 4 functions, which we will utilize later when handling requests and working with JWT tokens.

Add Request and Response Classes

Before we expose our REST endpoint, let’s take care of the request and response classes.

Let’s start by creating the routing.request package and implementing the UserRequest:

import kotlinx.serialization.Serializable

@Serializable
data class UserRequest(
  val username: String,
  val password: String,
)

As we can see, we must annotate objects that we want to serialize or deserialize using the @Serializable when working with kotlinx.serialization library.

As a result, the serialization plugin will automatically generate the implementation of KSerializer.

Similarly, let’s add the routing.response package and the UserResponse:

import kotlinx.serialization.Serializable
import java.util.*

@Serializable
data class UserResponse(
  val id: UUID,
  val username: String,
)

This one seems pretty similar, but unfortunately, we must do one more thing here.

Custom UUID KSerializer

If we would try to use this class as it is, we would end up with the following error:

Serializer has not been found for type ‘UUID’. To use context serializer as fallback, explicitly annotate type or property with @Contextual.

And to fix this issue, we must implement our own UUID serializer.

So as the first step, let’s introduce the util package and add the UUIDSerializer object:

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.*

object UUIDSerializer : KSerializer<UUID> {

  override val descriptor: SerialDescriptor
    get() = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)

  override fun deserialize(decoder: Decoder): UUID =
    UUID.fromString(decoder.decodeString())

  override fun serialize(encoder: Encoder, value: UUID) =
    encoder.encodeString(value.toString())
}

Following, let’s instruct the plugin to use our implementation whenever it wants to serialize the id field:

import com.codersee.util.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.*

@Serializable
data class UserResponse(
  @Serializable(with = UUIDSerializer::class)
  val id: UUID,
  val username: String,
)

Register Serialization Plugin

When working with Ktor, we must explicitly specify the plugins we use. There is no component scan based on the dependencies which you may know from Spring Boot 🙂

So as the next step, let’s add the Seriaization.kt to the plugins package:

import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*

fun Application.configureSerialization() {
  install(ContentNegotiation) {
    json()
  }
}

The ContentNegotiation plugin has two tasks:

  • it negotiates the media types between the client and server,
  • and serializes/deserializes the content in a specific format.

In our case, we can clearly see that we will be using the JSON format.

Note: other formats, like XML, CBOR, and ProtoBuf are supported, too.

Lastly, we must make use of our extension function inside the Application.kt file:

import com.codersee.plugins.configureSerialization
import io.ktor.server.application.*

fun main(args: Array<String>) {
  io.ktor.server.netty.EngineMain.main(args)
}

fun Application.module() {
  configureSerialization()
}

Add User Route

With all of that being done, we can finally expose endpoints for user management.

As the first step, let’s implement the Routing.kt class inside the routing package:

import com.codersee.service.UserService
import io.ktor.server.application.*
import io.ktor.server.routing.*

fun Application.configureRouting(
  userService: UserService
) {
  routing {

    route("/api/user") {
      userRoute(userService)
    }

  }
}

This way, we instruct Ktor that whenever a request is made to the /api/user**, it should look for a handler inside the userRoute (which we will add in a moment).

Following, let’s get back to the Application.kt and register our routing:

import com.codersee.plugins.configureSerialization
import com.codersee.repository.UserRepository
import com.codersee.routing.configureRouting
import com.codersee.service.UserService
import io.ktor.server.application.*

fun main(args: Array<String>) {
  io.ktor.server.netty.EngineMain.main(args)
}

fun Application.module() {
  val userRepository = UserRepository()
  val userService = UserService(userRepository)

  configureSerialization()
  configureRouting(userService)
}

Nextly, let’s add the UserRoute.kt inside the routing package:

import com.codersee.model.User
import com.codersee.routing.request.UserRequest
import com.codersee.routing.response.UserResponse
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.*
import java.util.*

fun Route.userRoute(userService: UserService) {

  post {
    val userRequest = call.receive<UserRequest>()

    val createdUser = userService.save(
      user = userRequest.toModel()
    ) ?: return@post call.respond(HttpStatusCode.BadRequest)

    call.response.header(
      name = "id",
      value = createdUser.id.toString()
    )
    call.respond(
      message = HttpStatusCode.Created
    )
  }

  get {
    val users = userService.findAll()

    call.respond(
      message = users.map(User::toResponse)
    )
  }

  get("/{id}") {
    val id: String = call.parameters["id"]
      ?: return@get call.respond(HttpStatusCode.BadRequest)

    val foundUser = userService.findById(id)
      ?: return@get call.respond(HttpStatusCode.NotFound)

    call.respond(
      message = foundUser.toResponse()
    )
  }
}

private fun UserRequest.toModel(): User =
  User(
    id = UUID.randomUUID(),
    username = this.username,
    password = this.password
  )

private fun User.toResponse(): UserResponse =
  UserResponse(
    id = this.id,
    username = this.username,
  )

And to be on the same page, let’s rephrase what is going on here:

import com.codersee.model.User
import com.codersee.routing.request.UserRequest
import com.codersee.routing.response.UserResponse
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.*
import java.util.*

fun Route.userRoute(userService: UserService) {

  post {
    val userRequest = call.receive<UserRequest>()

    val createdUser = userService.save(
      user = userRequest.toModel()
    ) ?: return@post call.respond(HttpStatusCode.BadRequest)

    call.response.header(
      name = "id",
      value = createdUser.id.toString()
    )
    call.respond(
      message = HttpStatusCode.Created
    )
  }

}

Firstly, we inject the instance of UserService, which we will use to save and retrieve users.

Nextly, we specify a handler for POST requests.

Inside this handler, we get the JSON payload sent by the API consumer as an instance of UserRequest with the call.receive<T>() function. Then, we invoke the save function and pass the instance of the User class, which we obtain using the toModel() extension function. If the save returns a null value, we return 400 Bad Request with the call.respond() function.

On the other hand, if the user was saved successfully, we respond with a 201 Created status and pass its identifier as the id header value with call.response.header() function.

Then, we have another handler responsible for GET /api/user requests:

get {
  val users = userService.findAll()

  call.respond(
    message = users.map(User::toResponse)
  )
}

It is responsible for fetching all users and returning them after mapping with toResponse() extension functions.

Lastly, we have the handler answering to the GET /api/user/{id} requests:

get("/{id}") {
  val id: String = call.parameters["id"]
    ?: return@get call.respond(HttpStatusCode.BadRequest)

  val foundUser = userService.findById(id)
    ?: return@get call.respond(HttpStatusCode.NotFound)

  call.respond(
    message = foundUser.toResponse()
  )
}

This time, we read the path variable id using the call.paramaters. If is not present, we return a 400 Bad Request response.

Following, we try to find a user by ID and if we fail, then we simply return the 404 Not Found.

Ktor With JWT

Excellent! At this point, we have a working API, so it’s finally time to learn a bit more about how to secure it, and how to generate JWT tokens in our Ktor application so that we can use them later to access these endpoints.

Update Config File

As the first step, let’s navigate to the application.conf file.

Among other settings, we should see the following jwt:

jwt {
    domain = "https://jwt-provider-domain/"
    audience = "jwt-audience"
    realm = "ktor sample app"
}

Let’s slightly modify it:

jwt {
    audience = "my-audience"
    issuer = "http://localhost/"
    realm = "My realm"
    secret = ${SECRET}
}

Without going into too much detail, these values (except for the secret) will be claims in our JWT tokens:

  • audience– which describes the recipient of the token,
  • issuer– which is the entity that issues the token,
  • realm– which is an optional parameter providing additional context or scope,
  • secret– which is a secret key used for token signing and verification.

Please note that the secret value IS NOT hardcoded (and should never be). We will source it from the environment variable called SECRET.

Implement JwtService

With that done, we can navigate to the service package and add the JwtService:

import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.codersee.model.User
import io.ktor.server.application.*
import io.ktor.server.auth.jwt.*
import java.util.*

class JwtService(
  private val application: Application,
  private val userService: UserService,
) {

  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 createJwtToken(loginRequest: LoginRequest): String? {
    val foundUser: User? = userService.findByUsername(loginRequest.username)

    return if (foundUser != null && loginRequest.password == foundUser.password)
      JWT.create()
        .withAudience(audience)
        .withIssuer(issuer)
        .withClaim("username", loginRequest.username)
        .withExpiresAt(Date(System.currentTimeMillis() + 3_600_000))
        .sign(Algorithm.HMAC256(secret))
    else
      null
  }

  fun customValidator(
    credential: JWTCredential,
  ): JWTPrincipal? {
    val username: String? = extractUsername(credential)
    val foundUser: User? = username?.let(userService::findByUsername)

    return foundUser?.let {
      if (audienceMatches(credential))
        JWTPrincipal(credential.payload)
      else
        null
    }
  }

  private fun audienceMatches(
    credential: JWTCredential,
  ): Boolean =
    credential.payload.audience.contains(audience)

  private fun getConfigProperty(path: String) =
    application.environment.config.property(path).getString()

  private fun extractUsername(credential: JWTCredential): String? =
    credential.payload.getClaim("username").asString()
}

Again, let’s break it down into smaller chunks:

import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.codersee.model.User
import io.ktor.server.application.*
import io.ktor.server.auth.jwt.*
import java.util.*

class JwtService(
  private val application: Application,
  private val userService: UserService,
) {

  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()

  private fun getConfigProperty(path: String) =
    application.environment.config.property(path).getString()
}

Firstly, we inject the instance of Application and UserService classes.

Following we read the configuration properties we set in the previous step. 3 of them can be private, but the realm will be later used in another class to configure security.

After that, we must create an instance of the JwtVerifier and configure it. In simple words, this class holds the verify method which will later verify that a given token has not only a proper JWT format but also its signature matches.

And this is why we specify which algorithm, audience, and issuer a valid token must have.

With all of that done, we introduce the createJwtToken function:

fun createJwtToken(loginRequest: LoginRequest): String? {
  val foundUser: User? = userService.findByUsername(loginRequest.username)

  return if (foundUser != null && loginRequest.password == foundUser.password)
    JWT.create()
      .withAudience(audience)
      .withIssuer(issuer)
      .withClaim("username", loginRequest.username)
      .withExpiresAt(Date(System.currentTimeMillis() + 3_600_000))
      .sign(Algorithm.HMAC256(secret))
  else
    null
}

This one takes the username as an argument and tries to find a user with it.

If we succeed and the password matches, then we produce a new JWT access token with the appropriate audience, issuer, and username, expiring in 60 minutes and signed with the HMAC-SHA256 algorithm (with our secret key).

Lastly, we add the customValidator function, which we will later use to add an additional validation:

fun customValidator(
  credential: JWTCredential,
): JWTPrincipal? {
  val username: String? = extractUsername(credential)
  val foundUser: User? = username?.let(userService::findByUsername)

  return foundUser?.let {
    if (audienceMatches(credential))
      JWTPrincipal(credential.payload)
    else
      null
  }
}

private fun audienceMatches(
  credential: JWTCredential,
): Boolean =
  credential.payload.audience.contains(audience)

private fun extractUsername(credential: JWTCredential): String? =
  credential.payload.getClaim("username").asString()

As the first thing, we extract the username from the JWT token and we try to find a user by this value.

If we succeed, then we check if the audience from the token matches the audience set in our Ktor project. If that’s true, then we instantiate the JWTPrincipal, which we will use later to obtain the information.

Install Authentication Plugin

At this point, we have the JWT service ready, so it’s time to install the Authentication plugin.

To do so, let’s add the Security plugin inside the plugins package:

import com.codersee.service.JwtService
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*

fun Application.configureSecurity(
  jwtService: JwtService
) {
  authentication {
    jwt {
      realm = jwtService.realm
      verifier(jwtService.jwtVerifier)

      validate { credential ->
        jwtService.customValidator(credential, jwtService)
      }
    }
  }
}

But what exactly is going on here?

The above code installs the Authentication plugin (authentication) with JWT Authentication provider (jwt).

Additionally, we must specify the realm, verifier, and the custom validate function that we implemented previously.

And what if we would like to have multiple providers in our project?

Well, we can do that easily by simply specifying a new one with a name:

import com.codersee.service.JwtService
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*

fun Application.configureSecurity(
  jwtService: JwtService
) {
  authentication {
    jwt {
      realm = jwtService.realm
      verifier(jwtService.jwtVerifier)

      validate { credential ->
        jwtService.customValidator(credential, jwtService)
      }
    }

    jwt("another-auth") {
      realm = jwtService.realm
      verifier(jwtService.jwtVerifier)

      validate { credential ->
        jwtService.customValidator(credential, jwtService)
      }
    }
  }
}

Later, we will see how to make use of these JWT auth providers, but for now, let’s navigate to the Application.kt file and make use of our new config:

import com.codersee.plugins.configureSecurity
import com.codersee.plugins.configureSerialization
import com.codersee.repository.UserRepository
import com.codersee.routing.configureRouting
import com.codersee.service.JwtService
import com.codersee.service.UserService
import io.ktor.server.application.*

fun main(args: Array<String>) {
  io.ktor.server.netty.EngineMain.main(args)
}

fun Application.module() {
  val userRepository = UserRepository()
  val userService = UserService(userRepository)
  val jwtService = JwtService(this, userService)

  configureSerialization()
  configureSecurity(jwtService)
  configureRouting(jwtService, userService)
}

Expose Login Endpoint

Perfect! At this point we have everything we need to expose a new endpoint- POST /api/auth– which we will use to get JWT access tokens for our Ktor app based on the passed username and password.

So as the first step, let’s go to the Routing file, inject the JwtService and prepare a new route:

import com.codersee.service.JwtService
import com.codersee.service.UserService
import io.ktor.server.application.*
import io.ktor.server.routing.*

fun Application.configureRouting(
  jwtService: JwtService,
  userService: UserService
) {
  routing {

    route("/api/auth") {
      authRoute(jwtService)
    }

    route("/api/user") {
      userRoute(userService)
    }

  }
}

Nextly, let’s implement the LoginRequest inside the routing.request:

import kotlinx.serialization.Serializable

@Serializable
data class LoginRequest(
  val username: String,
  val password: String,
)

No traps here this time, so let’s create the AuthRoute.kt file:

import com.codersee.routing.request.LoginRequest
import com.codersee.service.JwtService
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(jwtService: JwtService) {

  post {
    val user = call.receive<LoginRequest>()

    val token: String? = jwtService.createJwtToken(user.username)

    token?.let {
      call.respond(hashMapOf("token" to token))
    } ?: call.respond(
      message = HttpStatusCode.Unauthorized
    )
  }

}

This time, we read the request payload to the LoginRequest instance and we try to create a new JWT access token based on the username.

If we succeed (meaning a user with the following username was found and the password matches), then we return a fresh JWT access token. Otherwise, we simply return 401 Unauthorized to the API consumer.

At this point, we can test the app, for example by using curl (and if you would like to get a full Postman collection with automated token handling, then please leave a comment for this article).

Let’s start by creating a new user:

curl --location --request POST 'http://localhost:8080/api/user' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "user", 
    "password": "password"
}'

As a response, we should get 201 Created with the id in the response header.

Following, let’s try to get a JWT access token:

curl --location --request POST 'http://localhost:8080/api/auth' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "user", 
    "password": "password"
}'

// Response:

{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJteS1hdWRpZW5jZSIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3QvIiwidXNlcm5hbWUiOiJ1c2VyIiwiZXhwIjoxNzAwMTU2Mzg4fQ.DG8SsNrQI5-VqrZeZMFL4IRuVSLeo9Zs9dEifti9nvQ"
}

Excellent! So far so good.

You can even take this token and check it out using the jwt.io page 🙂

Secure User Endpoint

Finally, let’s secure user API endpoints:

fun Route.userRoute(userService: UserService) {

  post {
    val userRequest = call.receive<UserRequest>()

    val createdUser = userService.save(
      user = userRequest.toModel()
    ) ?: return@post call.respond(HttpStatusCode.BadRequest)

    call.response.header(
      name = "id",
      value = createdUser.id.toString()
    )
    call.respond(
      message = HttpStatusCode.Created
    )
  }

  authenticate {
    get {
      val users = userService.findAll()

      call.respond(
        message = users.map(User::toResponse)
      )
    }
  }

  authenticate("another-auth") {
    get("/{id}") {
      val id: String = call.parameters["id"]
        ?: return@get call.respond(HttpStatusCode.BadRequest)

      val foundUser = userService.findById(id)
        ?: return@get call.respond(HttpStatusCode.NotFound)

      if (foundUser.username != extractPrincipalUsername(call))
        return@get call.respond(HttpStatusCode.NotFound)

      call.respond(
        message = foundUser.toResponse()
      )
    }
  }
}

private fun UserRequest.toModel(): User =
  User(
    id = UUID.randomUUID(),
    username = this.username,
    password = this.password
  )

private fun User.toResponse(): UserResponse =
  UserResponse(
    id = this.id,
    username = this.username,
  )

private fun extractPrincipalUsername(call: ApplicationCall): String? =
  call.principal<JWTPrincipal>()
    ?.payload
    ?.getClaim("username")
    ?.asString()

As we can see, after our changes, every GET /api/user request will be verified using the provider, which we specified without the name.

When we check logs, we should even see the confirmation:

Matched routes:
“” -> “api” -> “user” -> “(authenticate “default”)” -> “(method:GET)”

On the other hand, every request to GET /api/user/{id} will be handled by the another-auth provider:

“” -> “api” -> “user” -> “(authenticate another-auth)” -> “{id}” -> “(method:GET)”

Lastly, it’s worth mentioning that we changed one more thing:

authenticate("another-auth") {
  get("/{id}") {
    val id: String = call.parameters["id"]
      ?: return@get call.respond(HttpStatusCode.BadRequest)

    val foundUser = userService.findById(id)
      ?: return@get call.respond(HttpStatusCode.NotFound)

    if (foundUser.username != extractPrincipalUsername(call))
      return@get call.respond(HttpStatusCode.NotFound)

    call.respond(
      message = foundUser.toResponse()
    )
  }
}


private fun extractPrincipalUsername(call: ApplicationCall): String? =
  call.principal<JWTPrincipal>()
    ?.payload
    ?.getClaim("username")
    ?.asString()

In this handler, we obtain the JWTPrincipal (created inside the JwtService) in order to read the username. If it matches with the found user, then we simply return the user response. But if not, then we return 404 Not Found.

Summary

And that’s all for this tutorial on how to implement a REST API with Ktor and Kotlin and how to secure it with JWT access tokens.

If you would like to find the whole source code, then check out this GitHub repository.

Lastly, if you enjoyed this tutorial, then please do not forget to share it with other people on your social media (it will help me a lot) and check out the continuation for this post:

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

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.