skip to content

In Ktor, authenticate {} proves identity — how do you enforce role checks per route?

level: seniorimportance: must knowfreq 58%

answer

  1. the plugin stops at identity
  2. no annotation is going to save you
  3. hook after authentication, before the handler
  4. 401 says who are you, 403 says no

basics

~20 s

Ktor ships no permission model, so authorization is code you add inside the authenticated subtree: either an explicit check in the handler, or a route-scoped plugin that hooks AuthenticationChecked, reads the principal's roles and responds 403 before the handler runs.

solid answer

~50 s

The `Authentication` plugin stops at identity: a principal exists, nothing more. There is no annotation and no role DSL, so you build the check. The crude version is an `if` at the top of each handler reading claims off `call.principal<...>()` and responding `HttpStatusCode.Forbidden` — correct but repeated everywhere and easy to omit. The maintainable version is a route-scoped plugin: `createRouteScopedPlugin("RequireRole", ::RoleConfig) { on(AuthenticationChecked) { call -> ... } }`, installed inside a `route` or `authenticate` block with its required roles. The `AuthenticationChecked` hook runs after authentication and before the handler, so responding 403 there stops the call for every route in that subtree at once. Keep the status codes honest — 401 means "authenticate", 403 means "authenticated but not permitted" — and remember that, as with `authenticate` itself, protection is structural: a route declared outside the guarded block gets no check and nothing warns you.

code

kotlin · 23 lines
kotlin
class RoleConfig {
    var required: Set<String> = emptySet()
}

val RequireRole = createRouteScopedPlugin("RequireRole", ::RoleConfig) {
    val required = pluginConfig.required
    on(AuthenticationChecked) { call ->
        val roles = call.principal<JWTPrincipal>()
            ?.payload?.getClaim("roles")?.asList(String::class.java).orEmpty()
        if (required.none { it in roles }) {
            call.respond(HttpStatusCode.Forbidden)
        }
    }
}

routing {
    authenticate("auth-jwt") {
        route("/admin") {
            install(RequireRole) { required = setOf("ADMIN") }
            get("/users") { call.respond(userService.all()) }
        }
    }
}

go deeper

for a junior

Know that authentication and authorization are separate in Ktor: authenticate says who the caller is, and any role check is code you write.

for a middle

Explain how to factor the check out of handlers — a route-scoped plugin hooked after authentication — and which status code each failure deserves.

for a senior

Show the production posture: fail closed, guard whole subtrees rather than endpoints, distinguish route-level from object-level authorization, and test that every route is covered.

for a principal

Own the policy model itself — roles versus scopes versus per-object rules, where it is evaluated, and how you keep it auditable as the route surface grows across teams.

## Why there is nothing to configure Ktor's `Authentication` plugin produces a principal and stops. It has no concept of roles, scopes or permissions, and there is no annotation processor to scan. That is deliberate — Ktor is assembled from explicit pieces — but it means every Ktor codebase invents its authorization layer, and interviewers ask this to see which of the three shapes you have actually built. ## Shape 1: inline checks ``` get("/admin/users") { val roles = call.principal<JWTPrincipal>()?.payload?.getClaim("roles")?.asList(String::class.java).orEmpty() if ("ADMIN" !in roles) return@get call.respond(HttpStatusCode.Forbidden) ... } ``` Honest and obvious, and fine for one or two endpoints. It degrades badly: the rule is copied, claim parsing is duplicated, and a new endpoint added by someone in a hurry simply has no check. Nothing in the type system or the build notices. ## Shape 2: a route-scoped plugin (the idiomatic answer) `createRouteScopedPlugin` builds a plugin you install on a specific route subtree rather than on the whole application. Combined with the `AuthenticationChecked` hook — which fires after the authentication providers have run and before the handler — it gives a clean interception point: ``` class RoleConfig { var required: Set<String> = emptySet() } val RequireRole = createRouteScopedPlugin("RequireRole", ::RoleConfig) { val required = pluginConfig.required on(AuthenticationChecked) { call -> val roles = call.principal<JWTPrincipal>() ?.payload?.getClaim("roles")?.asList(String::class.java).orEmpty() if (required.none { it in roles }) call.respond(HttpStatusCode.Forbidden) } } ``` Installed inside an authenticated subtree, `install(RequireRole) { required = setOf("ADMIN") }` now guards every route beneath it. The claim parsing lives in one place, the policy is declarative at the route, and adding an endpoint to that subtree inherits the guard automatically. Wrapping the install in a small extension function — `fun Route.requireRole(vararg roles: String, build: Route.() -> Unit)` that creates a child route, installs the plugin and applies `build` — makes call sites read almost like an annotation. Ordering matters: the plugin must be installed inside the `authenticate` block, otherwise the hook runs where no principal has been produced and every request looks unauthorized. ## Shape 3: authorization in the domain layer Route-level role checks cover "may this kind of user call this endpoint". They cannot express "may this user see *this* record", which is the more dangerous class of bug: the endpoint is allowed, the object is not theirs. That check belongs where the object is loaded — the query is scoped by the caller's tenant or ownership rather than filtered afterwards. A senior answer says both layers exist and that the coarse route guard does not replace the fine-grained one. ## Status codes - **401** — the caller is not authenticated. This is what the provider's challenge produces. - **403** — the caller is authenticated and still refused. This is what your authorization layer produces. Returning 401 for a permission failure sends clients into pointless re-login loops. Deliberately returning 404 to hide the existence of a resource is a legitimate choice for some products, but it must be a decision recorded in the contract, not an accident. ## Fail closed, and prove it Because both authentication and authorization are structural in Ktor, the default state of a newly added route is *unprotected*. Two habits contain that: 1. **One authenticated subtree** with guarded sub-subtrees, rather than `authenticate` blocks scattered through many files. New routes then land inside protection by default. 2. **A route-inventory test.** Enumerate the application's registered routes, call each without credentials and with an under-privileged principal, and assert 401/403 except for an explicit public allowlist. This is the closest Ktor gets to the guarantee an annotation-scanning framework provides, and it is the answer that distinguishes someone who has operated a Ktor service from someone who has read the docs. ## Where the roles come from Roles usually arrive as a claim on a verified token, or from the session's user record. Extract them once — in the provider's `validate` block, into a principal type of your own that already carries `userId`, `tenantId` and `roles` — so the authorization layer reads typed fields instead of re-parsing claims at every check.

  • Why install the authorization plugin inside the authenticate block rather than at application level?
    Because the check needs a principal. Installed application-wide it would also run on public routes, where no authentication has happened, and refuse them. Scoping it to a subtree inside `authenticate` means it guards exactly the routes that already have an authenticated caller.
  • Does a route-level role check cover a user requesting another user's record?
    No. It only answers whether this class of caller may call this endpoint. Object-level access must be enforced where the data is loaded — scope the query by the caller's ownership or tenant instead of fetching by id and filtering afterwards, which is the defect route guards routinely miss.
  • How do you keep a newly added route from being accidentally public?
    Structurally, keep a single authenticated subtree so new routes default to being inside it. Operationally, add a test that walks every registered route, calls it unauthenticated and with an under-privileged principal, and asserts 401/403 unless the path is on an explicit public allowlist.

saying these in an interview costs you the question

  • Expecting Ktor's auth plugin to enforce roles by itself
  • Responding 401 when the caller lacks permission
  • Installing the authorization plugin outside the authenticate block
  • Assuming route-level roles cover per-record ownership
  • Re-parsing role claims separately in every handler

context