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 aJWTPrincipalto accept the request, ornullto reject. (Here we just confirm a username claim exists.)challenge { }decides the response when auth fails — by default a bare401; we return our standardErrorResponseinstead.
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