Conquer Authentication with Ktor: Part 6 – Implementing JSON Web Tokens
Welcome back to our journey with the Ktor framework. Our previous post introduced you to JSON Web Tokens (JWT) and their impact on authentication in modern web applications. You learned about the key benefits of JWT, such as statelessness, improved scalability, cross-platform compatibility, and enhanced security. Today, we take things a step further with a hands-on approach, showing you how to effectively implement JWT using Ktor. Follow along as we dive into the practical side of JWT with Ktor to secure your web application seamlessly and effectively. By the end of this post, you’ll have a deeper understanding of how JWT and Ktor work together to create a robust and maintainable security model.
This piece is part of a practical, hands-on guide. You’re welcome to check out the project and code along at your own pace.
Table of Contents
- User Authentication: The Starting Point
- User Authorization with JWT
- Core Dependencies
- Security Configuration
- Implementing User Authentication
- Deconstructing JWT
- Accessing a Protected Resource
- Summary
User Authentication: The Starting Point
The journey starts with user authentication. Our server provides a login endpoint that can be used both internally or by external clients, such as mobile apps.
POST /login HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 56
{
"username": "user1",
"password": "password1"
}
If the credentials are valid the server generates and signs a JSON Web Token associated with the user’s identity and sends it in the response to the client. For example:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqd3QtYXVkaWVuY2UiLCJpc3MiOiJqd3QtaXNzdWVyIiwibmFtZSI6InVzZXIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDU2Mzk4Nzl9.FH-TPpIQRCIQ-RoW_J0U8YIENBE3FQDvYq3nkxCO_nc"
}
Wonder how to read and interpret the token? See the token anatomy and claims.
There’s a range of signing algorithms you can choose from. In this tutorial, we are using Hash-Based Message Authentication Codes algorithm, namely HS256 (HMAC SHA256). Other commonly used alternatives are:
- RS256 (RSA SHA256)
- PS512 (RSA PSS SHA512)
- ES384 (ECDSA using P384 curve and SHA384)
The choice of algorithm depends on your specific use case and security requirements. RSA (like RS256) or HMAC (like HS256) are most commonly used.
User Authorization with JWT
The client, such as a mobile app, can now access protected resources passing the token in the Authorization
header.
GET /me HTTP/1.1
Host: localhost:8080
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqd3QtYXVkaWVuY2UiLCJpc3MiOiJqd3QtaXNzdWVyIiwibmFtZSI6InVzZXIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDU2Mzk4Nzl9.FH-TPpIQRCIQ-RoW_J0U8YIENBE3FQDvYq3nkxCO_nc
Content-Type: application/json
In this scenario, the client requests access to the user’s profile. This would normally require user authentication. However, since the client passes a token, it receives a successful response. The client now acts on behalf of the user as long as the token remains valid.
Core Dependencies
To enable JWT in your Ktor application, you need include the following add-ons in your Gradle build file:
implementation("io.ktor:ktor-server-auth:$ktor_version")
implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
Next, install and configure the add-on.
Security Configuration
The configuration might look intimidating at first but don’t worry, it’s easy to understand once you know what to look for.
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.http.*
fun Application.configureSecurity() {
// Step 1: Load configuration
val authConfig = environment.config.config("ktor.auth.jwt")
// Step 2: Establish key actors in JWT flow
val jwtRealm = authConfig.property("realm").getString()
val jwtAudience = authConfig.property("audience").getString()
val jwtDomain = authConfig.property("domain").getString()
val jwtSecret = authConfig.property("secret").getString()
authentication {
jwt {
realm = jwtRealm
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtDomain)
.build()
)
// Step 3: JWT validation
validate { credential ->
if (credential.payload.audience.contains(jwtAudience))
JWTPrincipal(credential.payload)
else null
}
challenge {
call.respond(
HttpStatusCode.Unauthorized,
"Token is either invalid or expired."
)
}
}
}
}
There a few moving parts in here, let’s dissect them one by one.
Step 1 – Load of config values from a property file
Initially, we load all essential configuration values from a property file. This is aligned with best practices and prevents hard-coding of potentially sensitive information.
Step 2 – Configure JWT Verification
Here is a quick recap on the realm and claims:
- Realm: The realm essentially identifies the protection space, or the resource that is protected. For instance, a realm allows the HTTP server to distinguish among various protected areas or realms within a particular domain when communicating with the client. Understanding where the user is supposed to have rights to access can assist in managing user access and authorization.
- Audience: The audience is a claim inside the payload section of the JWT. It identifies the recipients the JWT is intended for. It’s a way of targeting where the JWT should be sent. If a JWT accidentally ends up at an unexpected system, or it is intercepted and resent, that system can check the aud claim and determine whether to process the JWT or reject it.
- Issuer: The issuer is a claim inside the payload section of the JWT. The iss claim is a case-sensitive string or a Uniform Resource Identifier (URI) that uniquely identifies the party who issued the JWT. In other words, it refers to the system or the server that generated and signed the JWT. By validating the iss claim, the recipient can ensure the token’s origin and confirm it was indeed issued by the expected party, thus raising trust in its authenticity.
The secret is a private key that is only known by the issuer (JWT server) and validator (API that requires JWT authentication). The key is used for signing the JWT token. The signature ensures that any modification to the generated JWT automatically renders the token invalid.
Step 3 – Configure Token Verification and Payload Validation
- Token Verifier: The
verifier
function verifies the token format and its signature. - Payload Validator: The
validate
function allows you to perform additional validation on the token’s payload. For example, you can check certain claims and their values. In this instance, we verify that the server is an intended recipient of the token by matching the value of theaud
claim. - Challenge Function: The
challenge
function determines an error response returned to the client in case the authentication fails.
Implementing User Authentication
For a user to acquire a JWT token, they must first go through the authentication process. As an example, we previously set up a login page for form-based authentication. However, keep in mind our aim is cross-platform compatibility – such as making the login accessible via a mobile app. We need to create a reliable authentication API – a login endpoint. This allows any client, such as a mobile app, to interact with the API by submitting the user’s credentials.
Full implementation details are beyond the scope of this post. However, the complete example is available on GitHub.
We begin by exposing a /login
route on the server.
import kotlinx.serialization.Serializable
routing {
post("/login") {
val login = call.receive<Login>()
// Authenticate the user
// Generate JWT token if the login is successful
}
}
@Serializable
data class Login(val username: String, val password: String)
I’ll leave it up to you to explore the authentication implementation details on your own. For the purpose of this discussion, let’s focus on the generation of a JWT token and its delivery to the client.
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm.HMAC256
routing {
post("/login") {
val login = call.receive<Login>()
// Assuming a valid login, generate a JWT token
val token = JWT.create()
.withAudience(jwtConfig.audience)
.withIssuer(jwtConfig.issuer)
.withClaim("name", login.username)
.withExpiresAt(
clock.instant()
.plusSeconds(jwtConfig.expirationSeconds)
)
.sign(HMAC256(jwtConfig.secret))
// Return the token in the response body
call.respond(mapOf("token" to token))
}
}
// A config instance is loaded upon server startup and passed to the router
data class JWTConfig(
val realm: String,
val secret: String,
val audience: String,
val issuer: String,
val expirationSeconds: Long
)
As you can see the generated JWT token includes all previously discussed claims. Note that the token has a limited timespan. In our example, the expiration is configurable and it’s initially set to one hour (3600 seconds). Feel free to check out the project and experiment with different expiration times, such as 15 minutes or even less – depending on your use case.
In summary, once the user successfully authenticates using their credentials, the server creates a fresh JWT token and returns it in the login response. It’s the responsibility of the client to intercept and store the provided token. From now, the client will use the token to act on behalf of the user.
Here is an example of a login request:
POST /login HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 56
{
"username": "user1",
"password": "password1"
}
The corresponding response includes the generated token:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqd3QtYXVkaWVuY2UiLCJpc3MiOiJqd3QtaXNzdWVyIiwibmFtZSI6InVzZXIxIiwiZXhwIjoxNzA1MTgyMjA4fQ.vFgisFZKB5JaOt6UVR-5FFJvusxeIvoy7JZB44BkwKE"
}
Deconstructing JWT
Let’s take a closer look at individual parts of the token (see the token anatomy for details).
Header: The header value is a Base64-encoded string that carries basic information about JWT and the algorithm: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
{"alg":"HS256","typ":"JWT"}
Payload: The payload value is a Base64-encoded string that contains claims and determines the token’s expiration: eyJhdWQiOiJqd3QtYXVkaWVuY2UiLCJpc3MiOiJqd3QtaXNzdWVyIiwibmFtZSI6InVzZXIxIiwiZXhwIjoxNzA1MTgyMjA4fQ
{"aud":"jwt-audience","iss":"jwt-issuer","name":"user1","exp":1705182208}
Signature: The signature is a product of signing the token with a secret key. It ensures the token’s authenticity: vFgisFZKB5JaOt6UVR-5FFJvusxeIvoy7JZB44BkwKE
Accessing a Protected Resource
After all the hard work setting things up, we’re finally ready for a test run. Let’s create two endpoints – one to show regular users their details, and another that’s just for admins to access.
authenticate("auth-jwt") {
get("/me") {
val principal = call.principal<JWTPrincipal>()
val name = principal?.name()
val ttl = principal?.ttl
call.respondText("Hello $name! Your token expires in $ttl ms.")
}
get("/admin") {
val principal = call.principal<JWTPrincipal>()
if (principal?.role() != "admin") {
call.respond(
HttpStatusCode.Forbidden,
"You are not authorized to access this resource!"
)
return@get
}
val name = principal?.name()
val ttl = principal?.ttl
call.respondText("Hello admin $name! Your token expires in $ttl ms.")
}
}
Starting with the /me endpoint:
GET /me HTTP/1.1
Host: localhost:8080
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJqd3QtYXVkaWVuY2UiLCJpc3MiOiJqd3QtaXNzdWVyIiwibmFtZSI6InVzZXIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDU3Mzg4MTV9.nCmmo6cqvDT_rthpD5-P13mIZom8Un1QOmV_qd4W8k0
Content-Type: application/json
Passing a valid token yields the expected success response:
Hello user1! Your token expires in 3579654 ms.
Using an expired token leads to a rejection. The error status and the message are determined by the challenge function:
Token to access WWW-Authenticate Bearer realm="ktor.io" is either invalid or expired.
Additionally, the /admin endpoint is only accessible to users with administrator privileges. This is where inspecting the role
claim plays a part.
Accessing /admin with a token generated for a regular user leads to a rejection:
You are not authorized to access this resource!
Summary
Kudos to you for making it to the end of this post! By now, you should have a firm grasp on implementing JWT into your own projects. Today, we’ve explored vital configuration steps and practical uses of JWT. In our next discussion, we’re going to enhance user experience by introducing an automatic token refresh when it expires. See you next time!