Ktor 3.6.0 Help

Type-safe authentication

Ktor provides a type-safe authentication scheme API that binds an authentication scheme to a principal type. You create a scheme once, pass that scheme to a route, and read the principal without a cast or a null check.

Add dependencies

To use typed authentication, add the ktor-server-auth artifact to your build script:

implementation("io.ktor:ktor-server-auth:$ktor_version")
implementation "io.ktor:ktor-server-auth:$ktor_version"
<dependency> <groupId>io.ktor</groupId> <artifactId>ktor-server-auth-jvm</artifactId> <version>${ktor_version}</version> </dependency>

If using the jwt scheme, add the ktor-server-auth-jwt artifact:

implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
implementation "io.ktor:ktor-server-auth-jwt:$ktor_version"
<dependency> <groupId>io.ktor</groupId> <artifactId>ktor-server-auth-jwt-jvm</artifactId> <version>${ktor_version}</version> </dependency>

If using the apiKey scheme, add the ktor-server-auth-api-key artifact:

implementation("io.ktor:ktor-server-auth-api-key:$ktor_version")
implementation "io.ktor:ktor-server-auth-api-key:$ktor_version"
<dependency> <groupId>io.ktor</groupId> <artifactId>ktor-server-auth-api-key-jvm</artifactId> <version>${ktor_version}</version> </dependency>

Enable the API

The API is marked with @ExperimentalKtorApi, so you must opt in:

@OptIn(ExperimentalKtorApi::class) fun Application.module() { // ... }

The route builders use Kotlin context parameters. Kotlin 2.4.0 enables context parameters by default. With Kotlin 2.2.x or 2.3.x, enable them in your build script:

kotlin { compilerOptions { freeCompilerArgs.add("-Xcontext-parameters") } }

Define a principal

A principal represents the identity or other information that your application associates with an authenticated caller. The principal type must be non-null.

data class User( val id: String, val email: String )

Create a scheme

Each authentication method provides a factory function that takes a principal type, a name, and a configuration block. The validate {} block returns your principal type, or null when the credentials are rejected:

val jwtAuth = jwt<User>("my-jwt") { realm = "my-app" verifier(jwkProvider, issuer) validate { credential -> val payload = credential.payload User( id = payload.subject, email = payload.getClaim("email").asString() ) } }

The factory returns a value. Store it and pass it to the routes that require it.

Factory

Artifact

Notes

basic<P>()

ktor-server-auth

See Basic authentication in Ktor Server

digest<P>()

ktor-server-auth

JVM only. See Digest authentication in Ktor Server

bearer<P>()

ktor-server-auth

See Bearer authentication in Ktor Server

form<P>()

ktor-server-auth

See Form-based authentication in Ktor Server

session<S, P>()

ktor-server-auth

See Type-safe session authentication

apiKey<P>()

ktor-server-auth-api-key

See API Key authentication

jwt<P>()

ktor-server-auth-jwt

JVM only. See JSON Web Tokens

oauth2(), oauth2Session<P, S>()

ktor-server-auth

See OAuth 2.0 flows

Scheme names must be unique. If you create two different schemes with the same name, the second one fails when a route tries to use it.

Protect routes

To protect routes, pass a scheme to the authenticateWith() function. Inside the block, call.principal has the principal type defined by the scheme and is guaranteed to be non-null:

routing { authenticateWith(jwtAuth) { get("/profile") { val user: User = call.principal call.respondText(user.email) } } }

Make authentication optional

Use the authenticateWithOptional() function when a route should serve both signed-in and anonymous callers. Inside the block, use call.principalOrNull to access the principal:

routing { authenticateWithOptional(jwtAuth) { get("/me") { val user = call.principalOrNull call.respondText(user?.email ?: "anonymous") } } }

A request without credentials succeeds and leaves call.principalOrNull as null. A request with invalid credentials still fails.

Accept multiple schemes

Use the authenticateWithAnyOf() function to accept more than one scheme on the same route. Ktor tries the schemes in the order you list them, and the first one that succeeds provides the principal.

All schemes must produce a principal that fits a common type, which you declare on the call:

interface AppUser { val email: String } data class JwtUser( override val email: String, val id: String ) : AppUser data class ApiUser(override val email: String) : AppUser val jwtScheme = jwt<JwtUser>("my-jwt") { /* ... */ } val apiKeyScheme = apiKey<ApiUser>("my-api-key") { /* ... */ } routing { // AppUser is authenticateWithAnyOf<AppUser>(jwtScheme, apiKeyScheme) { get("/profile") { call.respondText(call.principal.email) } } }

Allow anonymous callers

Use the orAnonymous() function to build a scheme that serves callers without credentials. A request without credentials gets the principal your block returns. A request with invalid credentials still fails.

The result is a scheme whose principal type is a common supertype of the two:

interface Identity data class AuthenticatedUser(val id: String) : Identity data class GuestUser(val label: String = "guest") : Identity val feedAuth = jwt<AuthenticatedUser>("my-jwt") { verifier(jwkProvider, issuer) validate { credential -> AuthenticatedUser(credential.payload.subject) } }.orAnonymous { GuestUser() } routing { authenticateWith(feedAuth) { get("/feed") { when (val user = call.principal) { is AuthenticatedUser -> call.respondText(user.id) is GuestUser -> call.respondText(user.label) } } } }

Transform a principal

Use the mapPrincipal() function to convert the principal into another type, for example, by loading a user record from your database:

data class AppUser(val id: String, val email: String) val appAuth = jwtAuth.mapPrincipal { jwtUser -> userDirectory.find(jwtUser.id)?.let { row -> AppUser(id = row.id, email = row.email) } }

The transform runs only after the scheme has produced a principal. If the transform returns null, the request is rejected with 401 Unauthorized.

Check roles

Role checks are opt-in. Define a role type that implements AuthenticationRole, then build a role-aware scheme with withRoles():

enum class Role : AuthenticationRole { User, Admin, Moderator } val roleAuth = jwtAuth.withRoles { user -> roleService.resolveRoles(user.id) }

The withRoles {} block runs on every request, after authentication succeeds. Use it to load roles from a database, a cache, or the principal itself.

Declare the roles a route requires with the roles parameter:

routing { authenticateWith(roleAuth, roles = setOf(Role.Admin)) { get("/admin") { val userRoles: Set<Role> = call.principal.roles call.respondText(userRoles.joinToString { it.name }) } } }

A caller who authenticates but lacks a required role receives a 403 Forbidden. A caller must have every role in the set, not just one of them.

Pass roles = null to resolve roles without requiring any. This is useful when the handler decides for itself:

authenticateWith(roleAuth, roles = null) { get("/dashboard") { if (Role.Admin in call.principal.roles) { call.respondText("admin view") } else { call.respondText("user view") } } }

The roles property exists only inside a role-aware route. On a route protected by a plain scheme, it does not compile.

Handle failures

Two things can go wrong, and they are handled separately:

  • onUnauthorized runs when authentication fails. The default response is 401 Unauthorized.

  • onForbidden runs when authentication succeeds but the caller lacks a required role. The default response is 403 Forbidden.

Set a handler on the scheme to cover every route that uses it:

val jwtAuth = jwt<User>("my-jwt") { verifier(jwkProvider, issuer) validate { credential -> /* ... */ } onUnauthorized = { cause -> val message = cause.toString() call.respond(HttpStatusCode.Unauthorized, message) } } val roleAuth = jwtAuth.withRoles( onForbidden = { val forbidden = HttpStatusCode.Forbidden call.respondText("Not allowed", status = forbidden) } ) { user -> roleService.resolveRoles(user.id) }

Or set a handler on a single route, which overrides the scheme-level one:

authenticateWith( roleAuth, roles = setOf(Role.Admin), onUnauthorized = { call.respondRedirect("/login") }, onForbidden = { val forbidden = HttpStatusCode.Forbidden call.respondText("Admins only", status = forbidden) }, ) { get("/admin") { call.respondText(call.principal.email) } }

Ktor looks for an unauthorized handler in the following order:

  1. The handler passed to authenticateWith().

  2. The onUnauthorized handler configured on the scheme.

  3. The provider's default challenge.

If none of them responds, the request fails with 401 Unauthorized.

For authenticateWithAnyOf(), the handler receives a map of scheme names to failure causes, so you can report why each one failed:

authenticateWithAnyOf<AppUser>( jwtScheme, apiKeyScheme, onUnauthorized = { failures -> val schemes = failures.keys.joinToString() call.respond(HttpStatusCode.Unauthorized, schemes) } ) { get("/profile") { /* ... */ } }

Combine with the classic API

Both authentication APIs can be used in the same application. You can nest a type-safe route inside a route protected by the named provider API, or nest a provider-based route inside a type-safe route:

routing { authenticate("auth-session") { authenticateWith(basicAuth) { get("/admin") { val basicUser = call.principal val sessionUser = checkNotNull(call.principal<SessionUser>()) if (basicUser.name == sessionUser.name) { call.respondText("You are ${basicUser.name}!") } else { call.respondText("Who are you?") } } } } authenticate(basicAuth.name) { get("/hello") { val user = checkNotNull(call.principal<BasicUser>()) call.respondText("Hello ${user.name}!") } } }

Nested authentication layers are applied in order.

Limitations

  • The API is experimental. It may change in a minor release.

  • The API uses Kotlin context parameters, which require Kotlin 2.4.0 or the -Xcontext-parameters compiler option.

  • The authenticateWithAnyOf() function provides only call.principal. Scheme-specific properties such as call.session are not available inside it.

  • There is no type-safe equivalent of the LDAP provider. Call ldapAuthenticate() inside a typed validate {} block instead.

14 September 2026