🔐 Layered JWT + RBAC Authorization in Ktor: My Scalable Approach
Michael Odhiambo

Michael Odhiambo @mikesplore

About: Yoooh

Location:
Mombasa
Joined:
May 5, 2024

🔐 Layered JWT + RBAC Authorization in Ktor: My Scalable Approach

Publish Date: Jul 20
1 0

Tags: #ktor #kotlin #backend #security #authentication #rbac

Modern backend development requires a secure and scalable approach to authentication and authorization. While Ktor provides a built-in way to handle authentication using Principal, I ran into issues getting it to reliably extract claims from JWT headers. So I built my own layered RBAC strategy — and it turned out to be both clean and powerful.

In this post, I’ll walk you through:

  • ✅ Why I used manual role checks (RBAC)
  • 🔐 How I layered JWT + Role access for security
  • 📦 How to make this structure scalable and maintainable

🎯 The Problem

Ktor’s default Principal-based JWT setup is elegant — but if the pipeline is misconfigured (or the JWT structure isn't what it expects), things silently fail.

In my case, the Authorization headers weren’t reliably read, and call.principal() was returning null even when tokens were valid. Debugging was messy.

I needed:

  • A top-level gate: only valid JWTs allowed.
  • Fine-grained role checks: only specific roles allowed per route.

🧱 The Architecture: Two-Layer Auth

I decided to split my authorization into two clean layers:

🧭 Layer 1: Global JWT Authentication

This layer ensures only requests with valid tokens reach the routes.

authenticate("jwt") {
    // All secured routes go here
}
Enter fullscreen mode Exit fullscreen mode

This acts like the main gatekeeper — the "watchman" who checks if you're allowed inside the compound.


🔑 Layer 2: Role-Based Access Control (withRole)

Once authenticated, I use a helper function to extract the user's role and allow or deny based on the endpoint.

suspend fun withRole(
    call: ApplicationCall,
    jwtService: JwtService,
    vararg allowedRoles: UserRole,
    handler: suspend () -> Unit
) {
    val userRole = extractUserRoleFromToken(call, jwtService)
    if (userRole == null || userRole !in allowedRoles) {
        call.respond(HttpStatusCode.Forbidden, "Access denied")
        return
    }
    handler()
}
Enter fullscreen mode Exit fullscreen mode

This gives me explicit control over who can access what.


🔍 Token Extraction: No Hidden Magic

suspend fun extractUserRoleFromToken(call: ApplicationCall, jwtService: JwtService): UserRole? {
    val header = call.request.headers[HttpHeaders.Authorization]
    val token = header?.removePrefix("Bearer ")?.trim() ?: return null

    return try {
        val jwt = jwtService.jwtVerifier.verify(token)
        val role = jwt.getClaim("role").asString()
        UserRole.valueOf(role)
    } catch (e: Exception) {
        null
    }
}
Enter fullscreen mode Exit fullscreen mode

I also extract the user's email similarly for profile-based actions.


🛠️ Usage Example

Here’s how I use it in my routing logic:

authenticate("jwt") {
    route("/users") {
        get {
            withRole(call, jwtService, UserRole.ADMIN, UserRole.USER) {
                call.respond(userService.getAllUsers())
            }
        }

        get("/me") {
            val email = extractUserEmailFromToken(call, jwtService)
            call.respond(userService.getUserByEmail(email))
        }

        get("/{id}") {
            withRole(call, jwtService, UserRole.ADMIN) {
                val id = call.parameters["id"]?.toIntOrNull()
                call.respond(userService.getUserProfile(id))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Simple. Clear. Testable.


📈 Scalable and Maintainable

This setup allowed me to:

  • ✅ Separate authentication from authorization
  • ✅ Apply access control per endpoint or entire route groups
  • ✅ Log and debug every step
  • ✅ Avoid the fragility of the Principal pipeline

Bonus: Group Wrappers

I could easily wrap entire routes:

fun Route.adminOnly(jwtService: JwtService, block: Route.() -> Unit) {
    route("") {
        intercept(ApplicationCallPipeline.Call) {
            val role = extractUserRoleFromToken(call, jwtService)
            if (role != UserRole.ADMIN) {
                call.respond(HttpStatusCode.Forbidden)
                finish()
            }
        }
        block()
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ Final Thoughts

If you're hitting weird issues with Principal, or just want more explicit, testable control over your routes, try this layered approach.

  • Top layer (authenticate("jwt")) ensures only valid tokens proceed.
  • Inner layer (withRole) checks fine-grained access.
  • Bonus wrappers like adminOnly {} make the routing cleaner.

Security doesn’t have to be abstract. Sometimes, simple, readable code wins.


🔗 Got Feedback?

Would love to hear how you're handling auth in Ktor — or improvements I can make to this structure. Drop your thoughts below! 🚀

Comments 0 total

    Add comment