Your API works. It has routes, a database, file uploads, and migrations. But anyone can access any endpoint. There is no authentication.
In this tutorial, you will add JWT (JSON Web Token) authentication. Users will register, login, get a token, and use that token to access protected routes.
How JWT Authentication Works
JWT authentication follows this flow:
1. Client sends email + password → POST /api/auth/login
2. Server verifies credentials
3. Server generates a JWT token
4. Server sends token to client
5. Client stores token
6. Client sends token with every request → Authorization: Bearer <token>
7. Server verifies token and processes request
The token contains encoded information (claims) about the user. The server can verify the token without a database query.
JWT Token Structure:
┌──────────────────────────────────────────────────┐
│ Header: {"alg": "HS256", "typ": "JWT"} │
│ Payload: {"userId": 1, "email": "alex@...", │
│ "exp": 1720000000} │
│ Signature: HMAC-SHA256(header + payload, secret) │
└──────────────────────────────────────────────────┘
Dependencies
Add these to your build.gradle.kts:
dependencies {
// Ktor Auth
implementation("io.ktor:ktor-server-auth:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")
// Password Hashing
implementation("at.favre.lib:bcrypt:0.10.2")
}
And add a migration for the password column:
-- V5__add_password_to_users.sql
ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
Password Hashing
Never store passwords in plain text. Use bcrypt to hash passwords.
import at.favre.lib.crypto.bcrypt.BCrypt
// Hash a password (during registration)
val hashedPassword = BCrypt.withDefaults()
.hashToString(12, password.toCharArray())
// Verify a password (during login)
val result = BCrypt.verifyer()
.verify(password.toCharArray(), storedHash)
if (result.verified) {
// Password is correct
}
The number 12 is the cost factor. Higher means slower (more secure) but takes more time. 12 is a good default.
Why bcrypt?
- Salted — Each password gets a unique random salt, so identical passwords produce different hashes
- Slow on purpose — Makes brute-force attacks impractical
- Industry standard — Used by most production applications
JWT Configuration
Create plugins/Authentication.kt:
object JwtConfig {
const val SECRET = "ktor-tutorial-secret-key-change-in-production"
const val ISSUER = "ktor-tutorial"
const val AUDIENCE = "ktor-tutorial-api"
const val REALM = "ktor-tutorial"
const val EXPIRATION_MS = 3_600_000L // 1 hour
fun generateToken(userId: Int, email: String): String {
return JWT.create()
.withAudience(AUDIENCE)
.withIssuer(ISSUER)
.withClaim("userId", userId)
.withClaim("email", email)
.withExpiresAt(Date(System.currentTimeMillis() + EXPIRATION_MS))
.sign(Algorithm.HMAC256(SECRET))
}
}
Key Parts
- SECRET — The key used to sign tokens. In production, use an environment variable
- ISSUER — Identifies who created the token (your server)
- AUDIENCE — Identifies who the token is for (your API)
- Claims — Custom data embedded in the token (userId, email)
- Expiration — When the token becomes invalid (1 hour)
Installing the Authentication Plugin
fun Application.configureAuthentication() {
install(Authentication) {
jwt("auth-jwt") {
realm = JwtConfig.REALM
verifier(
JWT.require(Algorithm.HMAC256(JwtConfig.SECRET))
.withAudience(JwtConfig.AUDIENCE)
.withIssuer(JwtConfig.ISSUER)
.build()
)
validate { credential ->
val userId = credential.payload.getClaim("userId").asInt()
val email = credential.payload.getClaim("email").asString()
if (userId != null && email != null) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse("Token is invalid or expired", 401)
)
}
}
}
}
How It Works
- verifier — Checks if the token was signed with our secret and has the right issuer/audience
- validate — Checks the token claims (userId, email) and returns a Principal or null
- challenge — Called when authentication fails (no token, invalid token, expired token)
Install it in your application module:
fun Application.module() {
configureDatabase()
configureSerialization()
configureAuthentication() // Before routes
configureStatusPages()
configureRouting()
}
User Repository Updates
Add registration and login methods:
class UserRepository {
// Register a new user with password hashing
suspend fun register(request: RegisterRequest): UserResponse = dbQuery {
val hashedPassword = BCrypt.withDefaults()
.hashToString(12, request.password.toCharArray())
val result = Users.insert {
it[name] = request.name
it[email] = request.email
it[passwordHash] = hashedPassword
}
resultRowToUser(result.resultedValues!!.first())
}
// Verify password for login
suspend fun verifyPassword(email: String, password: String): UserResponse? = dbQuery {
val row = Users.selectAll().where { Users.email eq email }
.singleOrNull() ?: return@dbQuery null
val storedHash = row[Users.passwordHash]
val result = BCrypt.verifyer().verify(password.toCharArray(), storedHash)
if (result.verified) {
resultRowToUser(row)
} else {
null
}
}
}
Auth Routes
Create routes/AuthRoutes.kt:
fun Route.authRoutes(userRepository: UserRepository) {
// Register
post("/auth/register") {
val request = call.receive<RegisterRequest>()
if (request.name.isBlank() || request.email.isBlank() || request.password.isBlank()) {
call.respond(HttpStatusCode.BadRequest,
ErrorResponse("Name, email, and password are required", 400))
return@post
}
if (request.password.length < 6) {
call.respond(HttpStatusCode.BadRequest,
ErrorResponse("Password must be at least 6 characters", 400))
return@post
}
val existing = userRepository.findByEmail(request.email)
if (existing != null) {
call.respond(HttpStatusCode.Conflict,
ErrorResponse("Email already registered", 409))
return@post
}
val user = userRepository.register(request)
val token = JwtConfig.generateToken(user.id, user.email)
call.respond(HttpStatusCode.Created, TokenResponse(token))
}
// Login
post("/auth/login") {
val request = call.receive<LoginRequest>()
if (request.email.isBlank() || request.password.isBlank()) {
call.respond(HttpStatusCode.BadRequest,
ErrorResponse("Email and password are required", 400))
return@post
}
val user = userRepository.verifyPassword(request.email, request.password)
if (user == null) {
call.respond(HttpStatusCode.Unauthorized,
ErrorResponse("Invalid email or password", 401))
return@post
}
val token = JwtConfig.generateToken(user.id, user.email)
call.respond(TokenResponse(token))
}
// Protected endpoint
authenticate("auth-jwt") {
get("/auth/me") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim("userId")?.asInt()
if (userId == null) {
call.respond(HttpStatusCode.Unauthorized,
ErrorResponse("Invalid token", 401))
return@get
}
val user = userRepository.findById(userId)
if (user == null) {
call.respond(HttpStatusCode.NotFound,
ErrorResponse("User not found", 404))
return@get
}
call.respond(user)
}
}
}
Protecting Routes
Use authenticate("auth-jwt") to protect any route:
routing {
route("/api") {
// Public routes - no authentication needed
authRoutes(userRepository)
// Protected routes - require JWT token
authenticate("auth-jwt") {
route("/notes") {
get { /* only authenticated users */ }
post { /* only authenticated users */ }
}
}
}
}
Getting User Info in Protected Routes
Inside an authenticate block, access the JWT claims:
authenticate("auth-jwt") {
get("/api/notes") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim("userId")?.asInt()
// Use userId to filter notes for this user
val notes = noteRepository.findAll(userId = userId)
call.respond(notes)
}
}
Request/Response Models
@Serializable
data class LoginRequest(
val email: String,
val password: String
)
@Serializable
data class RegisterRequest(
val name: String,
val email: String,
val password: String
)
@Serializable
data class TokenResponse(
val token: String,
val expiresIn: Long = 3600
)
Testing Authentication
@Test
fun `register returns token`() = testApplication {
application { module() }
val client = jsonClient()
val response = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("Alex", "alex@example.com", "password123"))
}
assertEquals(HttpStatusCode.Created, response.status)
val token = response.body<TokenResponse>()
assertNotNull(token.token)
}
@Test
fun `login with valid credentials returns token`() = testApplication {
application { module() }
val client = jsonClient()
// Register first
client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("Sam", "sam@example.com", "password123"))
}
// Login
val response = client.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest("sam@example.com", "password123"))
}
assertEquals(HttpStatusCode.OK, response.status)
}
@Test
fun `access protected route with valid token`() = testApplication {
application { module() }
val client = jsonClient()
// Register and get token
val tokenResponse = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("Taylor", "taylor@example.com", "password123"))
}.body<TokenResponse>()
// Access protected endpoint with token
val response = client.get("/api/auth/me") {
bearerAuth(tokenResponse.token)
}
assertEquals(HttpStatusCode.OK, response.status)
val user = response.body<UserResponse>()
assertEquals("Taylor", user.name)
}
@Test
fun `access protected route without token returns 401`() = testApplication {
application { module() }
val response = client.get("/api/auth/me")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
Testing with curl
# Register
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Alex", "email": "alex@example.com", "password": "password123"}'
# Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "alex@example.com", "password": "password123"}'
# Access protected route (replace TOKEN with actual token)
curl http://localhost:8080/api/auth/me \
-H "Authorization: Bearer TOKEN"
Security Best Practices
1. Use Environment Variables for Secrets
val secret = System.getenv("JWT_SECRET")
?: "fallback-for-development-only"
Never hardcode secrets in production.
2. Use HTTPS
JWT tokens travel in HTTP headers. Without HTTPS, anyone on the network can read them.
3. Set Token Expiration
Tokens should expire. One hour is a common choice. For longer sessions, use refresh tokens.
4. Validate All Claims
Check userId and email in the validate function. Do not accept tokens with missing claims.
5. Hash Passwords with bcrypt
Always use bcrypt (or Argon2) for password hashing. Never use MD5 or SHA for passwords.
6. Rate Limit Login Attempts
In production, add rate limiting to prevent brute-force attacks on the login endpoint.
Common Authentication Patterns
Protecting All API Routes
To protect all routes under /api except login and register:
routing {
route("/api") {
// Public routes
post("/auth/register") { /* ... */ }
post("/auth/login") { /* ... */ }
// Protected routes
authenticate("auth-jwt") {
get("/auth/me") { /* ... */ }
route("/notes") {
get { /* ... */ }
post { /* ... */ }
put("/{id}") { /* ... */ }
delete("/{id}") { /* ... */ }
}
route("/users") {
get { /* ... */ }
get("/{id}") { /* ... */ }
}
}
}
}
Owner-Only Access
Ensure users can only access their own data:
authenticate("auth-jwt") {
delete("/notes/{id}") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim("userId")?.asInt()
val note = noteRepository.findById(id)
if (note?.userId != userId) {
call.respond(HttpStatusCode.Forbidden,
ErrorResponse("You can only delete your own notes", 403))
return@delete
}
noteRepository.delete(id)
call.respond(HttpStatusCode.NoContent)
}
}
Role-Based Access
Add roles to the JWT token for admin features:
// When generating token
JWT.create()
.withClaim("userId", user.id)
.withClaim("email", user.email)
.withClaim("role", user.role) // "admin" or "user"
.sign(Algorithm.HMAC256(SECRET))
// In route handler
authenticate("auth-jwt") {
delete("/users/{id}") {
val principal = call.principal<JWTPrincipal>()
val role = principal?.payload?.getClaim("role")?.asString()
if (role != "admin") {
call.respond(HttpStatusCode.Forbidden,
ErrorResponse("Admin access required", 403))
return@delete
}
// Delete user
}
}
Token Expiration and Error Messages
When a token expires, the client gets a 401 response. The client should handle this by redirecting to the login page or refreshing the token.
Common JWT error scenarios:
| Scenario | HTTP Status | Message |
|---|---|---|
| No token provided | 401 | “Token is required” |
| Invalid token format | 401 | “Token is invalid” |
| Token expired | 401 | “Token has expired” |
| Wrong signature | 401 | “Token is invalid” |
| Valid token, user deleted | 404 | “User not found” |
| Valid token, insufficient role | 403 | “Forbidden” |
Refresh Tokens (Bonus)
For a better user experience, implement refresh tokens:
- Issue a short-lived access token (1 hour) and a long-lived refresh token (30 days)
- When the access token expires, the client uses the refresh token to get a new access token
- Store refresh tokens in the database (so you can revoke them)
This is beyond the scope of this tutorial, but it is the standard pattern for production applications.
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-11-jwt-auth
What’s Next?
Congratulations! You have built a complete REST API with Ktor. Your API has:
- Routing with path and query parameters
- JSON serialization with kotlinx.serialization
- Database with Exposed and H2
- Full CRUD operations
- Table relationships (one-to-many, many-to-many)
- File uploads and static files
- Database migrations with Flyway
- JWT authentication with password hashing
In the next tutorials, we will add more advanced features like WebSockets, caching, rate limiting, Docker deployment, and testing strategies.
Related Articles
- Ktor Tutorial #10: Database Migrations — Flyway migrations
- Ktor Tutorial #9: File Uploads — Multipart and static files
- Ktor Tutorial #7: CRUD Operations — Building a REST API
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch