Locking the Doors: JWT Authentication


Right now anyone can delete any task. For a real API you need to know who’s calling and whether they’re allowed. The common answer for stateless APIs is JWT — JSON Web Tokens — and Ktor’s Authentication plugin makes it a few blocks of config.

How JWT works, briefly

A JWT is a string with three parts: a header, a payload of claims (facts like “username: alice”, plus an expiry), and a signature. The server signs the token with a secret key at login. On later requests the client sends it back in the Authorization: Bearer <token> header, and the server verifies the signature. If it’s valid and unexpired, the request is authenticated. Nothing is stored server-side; the signed token is the proof. That statelessness is why JWT suits APIs.

Dependencies and config

Add the auth plugins:

implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-auth-jwt")

The signing secret, issuer, and audience go in application.yaml — never in code:

jwt:
  secret: "change-me-in-production"
  issuer: "task-api"
  audience: "task-api-users"
  realm: "Task API"

In production you’d override jwt.secret with an environment variable — we’ll cover that in the deployment post. A secret in source control is a secret that’s already leaked.

Installing the verifier

The Authentication plugin holds one or more named auth schemes. Here’s a JWT one called auth-jwt:

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*

fun Application.configureSecurity() {
    val config = environment.config
    val secret = config.property("jwt.secret").getString()
    val issuer = config.property("jwt.issuer").getString()
    val audience = config.property("jwt.audience").getString()

    install(Authentication) {
        jwt("auth-jwt") {
            realm = config.property("jwt.realm").getString()
            verifier(
                JWT.require(Algorithm.HMAC256(secret))
                    .withIssuer(issuer)
                    .withAudience(audience)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("username").asString().isNotEmpty()) {
                    JWTPrincipal(credential.payload)
                } else null
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Token is invalid or expired"))
            }
        }
    }
}

Three pieces do the work:

  • verifier(...) checks the signature, issuer, and audience. A tampered or wrongly-signed token fails here.
  • validate { } runs after a valid signature — your chance to apply business rules on the claims. Return a JWTPrincipal to accept the request, or null to reject. (Here we just confirm a username claim exists.)
  • challenge { } decides the response when auth fails — by default a bare 401; we return our standard ErrorResponse instead.

Issuing tokens at login

Auth needs a way in. A login route checks credentials (against your user store — simplified here) and mints a token:

import java.util.Date

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

fun Route.authRoutes() {
    val secret = application.environment.config.property("jwt.secret").getString()
    val issuer = application.environment.config.property("jwt.issuer").getString()
    val audience = application.environment.config.property("jwt.audience").getString()

    post("/login") {
        val creds = call.receive<LoginRequest>()

        // Replace with a real user/password check:
        if (creds.username != "alice" || creds.password != "secret") {
            throw ValidationException("Invalid credentials")
        }

        val token = JWT.create()
            .withIssuer(issuer)
            .withAudience(audience)
            .withClaim("username", creds.username)
            .withExpiresAt(Date(System.currentTimeMillis() + 3_600_000)) // 1 hour
            .sign(Algorithm.HMAC256(secret))

        call.respond(mapOf("token" to token))
    }
}

The client POSTs credentials, gets a token back, and sends it on every subsequent request.

Protecting routes

Wrap any routes that need auth in authenticate("auth-jwt") { }. Only requests with a valid token reach the handlers inside:

fun Application.configureRouting(service: TaskService) {
    routing {
        authRoutes()              // /login — open

        authenticate("auth-jwt") {
            taskRoutes(service)   // all /tasks routes — now protected
        }
    }
}

You decide the boundary by where you place the authenticate block. Public routes (login, health checks) sit outside it; everything sensitive goes in.

Reading the current user

Inside a protected handler, pull the principal out of the call to know who’s asking:

get("/me") {
    val principal = call.principal<JWTPrincipal>()
    val username = principal!!.payload.getClaim("username").asString()
    call.respondText("You are logged in as $username")
}

That username is your hook for authorization — the next step beyond authentication. Once you know who the caller is, you can check whether this user owns this task before letting them delete it. Authentication is “who are you?”; authorization is “are you allowed?”, and the principal is where you start answering the second question.

Final thoughts

JWT auth in Ktor is three moving parts: a login route that signs a token, a jwt { } block that verifies it, and authenticate("auth-jwt") { } wrappers that mark which routes require one. The boundary is explicit and lives in your routing, so there’s never a question of what’s protected. Two rules to carry forward: keep the secret out of source and in config, and treat the principal as the starting point for the authorization checks your real app will need.

Next: validation and security — rejecting bad input before it reaches your logic, plus CORS and rate limiting for the public internet.

Comments