skip to content

Authentication

Ktor's auth plugin configures named providers — JWT, sessions, basic, form — and authenticate blocks wrap the routes that need them, exposing a principal. Interviewers ask how per-route authorization is expressed, since Ktor has no annotation model.

on this pageshow

questions

5

In Ktor, how do you protect a route with the Authentication plugin and read the principal?

level: juniorimportance: must knowfreq 76%

answer

  1. Ktor has no auth annotations
  2. providers get names, routes get wrapped
  3. validate returns a principal or null
  4. call.principal<T>() inside the handler

basics

~10 s

Install Authentication and configure a named provider, then wrap the routes in authenticate("name") { }. Inside a handler, call.principal<T>() returns whatever the provider's validate block produced; routes outside the block stay public.

solid answer

~40 s

Ktor has no annotations — protection is structural. First `install(Authentication)` and declare one or more named providers, e.g. `basic("auth-basic") { realm = "..."; validate { credentials -> ... } }`. The `validate` block is your code: it receives the parsed credential, checks it, and returns a principal object on success or `null` on failure. Then in `routing`, wrap the protected routes in `authenticate("auth-basic") { ... }`. Any route nested inside runs the provider before the handler; any route outside is public, with nothing to warn you. In the handler, `call.principal<UserIdPrincipal>()` retrieves what `validate` returned, so identity flows to your code without thread-locals or a security context. If `validate` returns null, the provider's challenge runs instead of the handler — a 401 for `basic` by default.

code

kotlin · 23 lines
kotlin
install(Authentication) {
    basic("auth-basic") {
        realm = "Access to the admin area"
        validate { credentials ->
            if (users.verify(credentials.name, credentials.password)) {
                UserIdPrincipal(credentials.name)
            } else {
                null
            }
        }
    }
}

routing {
    get("/health") { call.respondText("OK") }          // public: outside the block

    authenticate("auth-basic") {
        get("/admin/stats") {
            val who = call.principal<UserIdPrincipal>()?.name
            call.respondText("hello $who")
        }
    }
}

go deeper

for a junior

Be able to write the shape from memory: install Authentication, declare a named provider with a validate block, wrap routes in authenticate("name"), read call.principal<T>().

for a middle

Explain the mechanics — validate returns a principal or null, null triggers the challenge, the principal is attached to the call, and the provider name links configuration to routes.

for a senior

Show the operational concern: protection is structural, so an unnested route is silently public. Describe how you keep the authenticated surface auditable and tested.

for a principal

Own the boundary design — one authenticated subtree versus many, which mechanisms coexist, and how identity is represented in a principal type the whole codebase depends on.

## The three moving parts Ktor's auth story has exactly three pieces, and naming them cleanly is most of the answer. **1. The plugin and its providers.** `install(Authentication)` (artifact `ktor-server-auth`) opens a configuration block in which you declare providers. Each provider is a mechanism plus a name: `basic("auth-basic") { ... }`, `form("auth-form") { ... }`, `jwt("auth-jwt") { ... }`, `session<UserSession>("auth-session") { ... }`, `bearer("auth-bearer") { ... }`, `oauth("auth-oauth") { ... }`. The name is the handle you refer to later. A provider declared with no name becomes the default provider, used by a bare `authenticate { }`. Named providers are strongly preferred in anything beyond a demo, because a service usually ends up with more than one mechanism. **2. `validate` — your decision function.** Every provider ends in a lambda you write. For `basic` it receives `UserPasswordCredential` with `name` and `password`; for `jwt` a `JWTCredential` holding the verified payload; for `session` the deserialized session object. You return a principal object on success or `null` on failure. Returning null is how you reject — you do not throw, and you do not respond yourself. Ktor treats null as "this provider did not authenticate" and runs the challenge. The object you return is arbitrary. Ktor ships `UserIdPrincipal(name)` and `JWTPrincipal(payload)` for convenience, but returning your own type carrying a user id, tenant and roles is normal and usually better — it is exactly what your handlers need. **3. `authenticate` — the route wrapper.** In `routing`, `authenticate("auth-basic") { get("/admin") { ... } }` creates a child route on which the named provider runs before the handler. Nesting is the enforcement: routes inside are protected, routes outside are not, and there is no annotation scanner or component scan that will notice a route you forgot to nest. ## Reading the principal Inside a handler, `call.principal<UserIdPrincipal>()` (or your own type) returns the object `validate` produced, or null. The reified type is a cast — asking for the wrong type yields null rather than an error, which is the usual cause of "my principal is null even though auth passed". Since the value hangs off the `ApplicationCall`, it flows naturally through suspending code without a thread-bound context, which is one of the ergonomic differences of a coroutine-based server. ## Options worth knowing early - **`authenticate(optional = true)`** runs the provider but does not challenge when it fails; the handler executes with a null principal. Useful for endpoints that show more to a signed-in user. - **Several names**, `authenticate("auth-jwt", "auth-basic") { }`, tries the listed providers; by default the first one that succeeds wins, and `AuthenticationStrategy` lets you demand all of them or make them optional. - **Nesting** works: an outer `authenticate` block can contain `route` blocks and further nesting, so a whole `/api` subtree is protected in one place. ## The failure mode interviewers probe Because protection is structural, the risk is a route defined in the wrong place. A new endpoint added at the bottom of `routing { }` instead of inside the `authenticate` block is world-readable and compiles fine. Teams that take this seriously either keep a single top-level `authenticate` block for the whole authenticated surface, or add a test that walks every registered route unauthenticated and asserts 401 for everything not on an explicit allowlist. ## What this is not `authenticate` establishes *who* the caller is. It says nothing about *what* they may do; Ktor has no role model built into the plugin, so permission checks are code you add. Keeping that distinction crisp in the answer — provider proves identity, your code enforces permission — signals that you have built something real with it.

  • What happens if you list two providers, as in authenticate("auth-jwt", "auth-basic")?
    Both are candidates for the wrapped routes. By default the first provider that produces a principal wins and the rest are skipped; `AuthenticationStrategy` on the `authenticate` call changes that, letting you require every listed provider to succeed or make them optional instead of challenging.
  • Why might call.principal<T>() return null on a route that clearly authenticated?
    Most often the requested type does not match what `validate` returned — asking for `UserIdPrincipal` when the provider returns your own user type yields null, since the lookup is a typed cast. The other cause is `authenticate(optional = true)`, where an unauthenticated request reaches the handler by design.
  • How do you stop someone adding an unprotected route by accident?
    Keep one authenticated subtree rather than scattering `authenticate` blocks, so the default position for a new route is inside it, and back it with a test that enumerates the application's registered routes, calls each without credentials, and asserts 401 except for an explicit public allowlist.

saying these in an interview costs you the question

  • Expecting an annotation to protect a Ktor endpoint
  • Throwing or responding inside validate instead of returning null
  • Assuming authenticate also enforces roles
  • Believing routes outside the block inherit protection
  • Requesting the wrong principal type and blaming the provider

context

open as a page

In Ktor's jwt auth provider, what do the verifier and validate blocks each do?

level: middleimportance: must knowfreq 66%

basics

~20 s

In Ktor's jwt provider, verifier supplies the token verifier that checks the signature and standard claims before your code runs; validate then receives the already-verified JWTCredential and decides, in application terms, whether to return a principal or null.

open as a page

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

level: seniorimportance: must knowfreq 58%

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.

open as a page

In Ktor, how does session-based authentication work with the Sessions plugin?

level: middleimportance: should knowfreq 54%

basics

~10 s

Two plugins cooperate: Sessions defines a typed session carried in a cookie, and Authentication's session provider validates it per request. A login route calls call.sessions.set(...); protected routes wrap in authenticate and read the principal.

open as a page

In Ktor, what does an authentication provider's challenge block control?

level: seniorimportance: should knowfreq 44%

basics

~20 s

The challenge block defines the response sent when no provider authenticated the caller — the default 401 with a WWW-Authenticate header for basic, or whatever you write: a JSON error body for an API, a redirect to a login page for a browser application.

open as a page