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

  1. verifier — Checks if the token was signed with our secret and has the right issuer/audience
  2. validate — Checks the token claims (userId, email) and returns a Principal or null
  3. 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:

ScenarioHTTP StatusMessage
No token provided401“Token is required”
Invalid token format401“Token is invalid”
Token expired401“Token has expired”
Wrong signature401“Token is invalid”
Valid token, user deleted404“User not found”
Valid token, insufficient role403“Forbidden”

Refresh Tokens (Bonus)

For a better user experience, implement refresh tokens:

  1. Issue a short-lived access token (1 hour) and a long-lived refresh token (30 days)
  2. When the access token expires, the client uses the refresh token to get a new access token
  3. 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.