Resposta rápida: para proteger uma API Kotlin com Ktor Authentication e JWT, instale os plugins Authentication e ContentNegotiation, crie um endpoint de login que valide as credenciais, assine um token curto no servidor e configure o provider jwt para verificar assinatura, emissor, audiência e claims. Depois, coloque as rotas privadas dentro de authenticate("auth-jwt"). Em produção, mantenha a chave fora do código, use HTTPS, não grave senha em texto puro, valide autorização além da autenticação e trate refresh tokens como credenciais revogáveis.

JWT resolve uma parte específica da segurança: permite que a API reconheça uma identidade sem consultar uma sessão central a cada requisição. Ele não resolve sozinho armazenamento de senhas, permissões, logout imediato, proteção contra abuso ou segurança do cliente. Um bom projeto separa autenticação — “quem é você?” — de autorização — “o que você pode fazer?”.

Este guia monta um fluxo completo com Ktor, explica as decisões de segurança, adiciona roles, refresh token e testes e mostra os erros que mais aparecem quando uma aplicação sai do tutorial e chega à produção.

Como funciona Ktor Authentication com JWT?

O fluxo básico possui cinco passos:

  1. o cliente envia e-mail e senha para POST /auth/login;
  2. o servidor procura a conta e compara a senha com um hash seguro;
  3. se as credenciais forem válidas, o servidor assina um access token;
  4. o cliente envia Authorization: Bearer <token> nas rotas privadas;
  5. o plugin do Ktor valida o token e cria um JWTPrincipal para o handler.

O token costuma carregar claims pequenas, como identificador do usuário e role. Não coloque senha, documento, endereço ou dados sensíveis no payload. JWT assinado tem integridade, mas seu conteúdo normalmente é apenas codificado em Base64URL e pode ser lido por quem possuir o token.

Se você ainda está montando routing, serialização e tratamento de erros, comece pelo tutorial de APIs REST com Ktor e Kotlin e volte para acrescentar autenticação depois que a API pública estiver funcionando.

Dependências no Gradle Kotlin DSL

Use versões compatíveis entre todos os artefatos Ktor e centralize a versão no version catalog quando o projeto tiver vários módulos.

// build.gradle.kts

dependencies {
    implementation("io.ktor:ktor-server-core:<versao-atual>")
    implementation("io.ktor:ktor-server-netty:<versao-atual>")
    implementation("io.ktor:ktor-server-content-negotiation:<versao-atual>")
    implementation("io.ktor:ktor-serialization-kotlinx-json:<versao-atual>")
    implementation("io.ktor:ktor-server-auth:<versao-atual>")
    implementation("io.ktor:ktor-server-auth-jwt:<versao-atual>")

    implementation("ch.qos.logback:logback-classic:<versao-compativel>")

    testImplementation("io.ktor:ktor-server-test-host:<versao-atual>")
    testImplementation(kotlin("test"))
}

O placeholder evita transformar este artigo em fonte de uma versão fixa que pode ficar desatualizada. No projeto real, fixe as versões e deixe a atualização acontecer por pull request. O guia de Gradle com Kotlin mostra como organizar plugins, dependências e catálogos.

Modele configuração, login e resposta

Comece com objetos pequenos e explícitos. A chave de assinatura deve vir de uma variável de ambiente ou de um gerenciador de segredos, nunca de um literal commitado.

import kotlinx.serialization.Serializable

@Serializable
data class LoginRequest(
    val email: String,
    val password: String
)

@Serializable
data class TokenResponse(
    val accessToken: String,
    val tokenType: String = "Bearer",
    val expiresInSeconds: Long
)

data class JwtSettings(
    val secret: String,
    val issuer: String,
    val audience: String,
    val realm: String,
    val accessTokenTtlSeconds: Long = 900
)

Uma configuração de produção pode carregar JWT_SECRET, JWT_ISSUER e JWT_AUDIENCE do ambiente. Falhe durante o startup se um valor obrigatório estiver ausente; iniciar com uma chave padrão fraca é pior que interromper o deploy.

fun loadJwtSettings(): JwtSettings = JwtSettings(
    secret = requireNotNull(System.getenv("JWT_SECRET")) {
        "JWT_SECRET não configurado"
    },
    issuer = System.getenv("JWT_ISSUER") ?: "https://api.exemplo.com",
    audience = System.getenv("JWT_AUDIENCE") ?: "api-exemplo",
    realm = "api-exemplo"
)

Em ambientes com múltiplas réplicas, todas as instâncias que validam o mesmo token precisam compartilhar a chave adequada ou usar um esquema assimétrico com chave privada para assinatura e chave pública para verificação.

Gere o access token no servidor

O exemplo abaixo usa HMAC para manter o fluxo didático. Em uma organização com vários serviços, algoritmos assimétricos podem facilitar distribuição de chaves públicas e rotação.

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import java.time.Instant
import java.util.Date

class TokenService(
    private val settings: JwtSettings
) {
    private val algorithm = Algorithm.HMAC256(settings.secret)

    fun createAccessToken(user: AuthenticatedUser): String {
        val now = Instant.now()
        val expiresAt = now.plusSeconds(settings.accessTokenTtlSeconds)

        return JWT.create()
            .withIssuer(settings.issuer)
            .withAudience(settings.audience)
            .withSubject(user.id.toString())
            .withClaim("role", user.role)
            .withIssuedAt(Date.from(now))
            .withExpiresAt(Date.from(expiresAt))
            .sign(algorithm)
    }
}

data class AuthenticatedUser(
    val id: Long,
    val email: String,
    val role: String
)

Use sub para o identificador estável da identidade. Evite usar e-mail como subject quando ele puder mudar. Defina expiração curta para o access token; quinze minutos é um ponto de partida comum, não uma regra universal.

O relógio do servidor e dos serviços verificadores precisa estar sincronizado. Diferenças de horário podem fazer tokens recém-criados parecerem expirados ou ainda inválidos.

Configure o provider JWT no Ktor

A validação deve conferir mais do que a assinatura. Emissor e audiência impedem que um token emitido para outro sistema seja aceito acidentalmente.

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.serialization.kotlinx.json.json

fun Application.configureSecurity(settings: JwtSettings) {
    install(ContentNegotiation) {
        json()
    }

    install(Authentication) {
        jwt("auth-jwt") {
            realm = settings.realm

            verifier(
                JWT.require(Algorithm.HMAC256(settings.secret))
                    .withIssuer(settings.issuer)
                    .withAudience(settings.audience)
                    .build()
            )

            validate { credential ->
                val subject = credential.payload.subject
                val role = credential.payload.getClaim("role").asString()

                if (!subject.isNullOrBlank() && !role.isNullOrBlank()) {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }

            challenge { _, _ ->
                call.respond(
                    HttpStatusCode.Unauthorized,
                    mapOf("error" to "token_invalido_ou_expirado")
                )
            }
        }
    }
}

O bloco validate decide se o payload possui o mínimo necessário para virar uma identidade dentro da aplicação. Não faça uma consulta pesada ao banco em toda requisição sem medir o custo. Quando a regra exige status atualizado da conta ou revogação imediata, avalie cache, sessão opaca ou uma lista de revogação.

Crie o endpoint de login

O endpoint recebe credenciais, delega a verificação a um serviço e devolve o token. A rota não deve conhecer detalhes do algoritmo de hash.

import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing

fun Application.configureAuthRoutes(
    authService: AuthService,
    tokenService: TokenService,
    settings: JwtSettings
) {
    routing {
        route("/auth") {
            post("/login") {
                val request = call.receive<LoginRequest>()
                val user = authService.authenticate(request.email, request.password)

                if (user == null) {
                    call.respond(
                        HttpStatusCode.Unauthorized,
                        mapOf("error" to "credenciais_invalidas")
                    )
                    return@post
                }

                call.respond(
                    TokenResponse(
                        accessToken = tokenService.createAccessToken(user),
                        expiresInSeconds = settings.accessTokenTtlSeconds
                    )
                )
            }
        }
    }
}

Retorne uma mensagem genérica para credenciais inválidas. Dizer “e-mail não existe” e “senha incorreta” separadamente facilita enumeração de contas.

Na persistência, armazene hashes produzidos por um algoritmo apropriado para senhas, como Argon2id ou bcrypt com custo configurado. Um hash rápido de uso geral, como SHA-256 isolado, não é substituto para password hashing. Também aplique rate limiting no login; o guia de rate limiting com Kotlin, Spring e Ktor mostra as opções por IP, usuário e endpoint.

Proteja rotas com authenticate

Depois que o provider está instalado, agrupe as rotas privadas sob o mesmo nome.

import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing

fun Application.configurePrivateRoutes() {
    routing {
        authenticate("auth-jwt") {
            get("/me") {
                val principal = call.principal<JWTPrincipal>()!!
                val userId = principal.payload.subject
                val role = principal.payload.getClaim("role").asString()

                call.respond(
                    mapOf(
                        "userId" to userId,
                        "role" to role
                    )
                )
            }
        }
    }
}

O !! é aceitável nesse ponto somente porque a rota está dentro de um provider que cria JWTPrincipal quando a validação passa. Se a configuração permitir múltiplos providers ou principals, trate a ausência explicitamente.

Uma requisição válida fica assim:

GET /me HTTP/1.1
Host: api.exemplo.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

Documente o bearer token no contrato da API. O tutorial de OpenAPI e Swagger com Ktor ajuda a representar respostas 401, o security scheme e as rotas protegidas.

Autenticação não é autorização

Um token válido prova que a requisição apresentou uma credencial aceita. Ele não significa que qualquer pessoa autenticada pode administrar usuários ou acessar recursos de outra conta.

Centralize a verificação de role ou permissão em uma função reutilizável:

import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.response.respond

suspend fun ApplicationCall.requireRole(expected: String): JWTPrincipal? {
    val principal = principal<JWTPrincipal>()
    val role = principal?.payload?.getClaim("role")?.asString()

    if (principal == null || role != expected) {
        respond(
            HttpStatusCode.Forbidden,
            mapOf("error" to "permissao_insuficiente")
        )
        return null
    }

    return principal
}
post("/admin/relatorios") {
    val principal = call.requireRole("ADMIN") ?: return@post
    call.respond(mapOf("requestedBy" to principal.payload.subject))
}

Use 401 Unauthorized quando a credencial está ausente ou inválida e 403 Forbidden quando a identidade foi reconhecida, mas não possui permissão.

Roles simples atendem muitos sistemas. Quando as regras dependem do recurso — por exemplo, “pode editar apenas o próprio pedido” — verifique ownership e políticas do domínio, não apenas uma string no token.

Access token e refresh token

Um access token curto reduz a janela de exposição, mas obriga o cliente a autenticar novamente quando expira. O refresh token permite emitir um novo access token sem pedir a senha a cada quinze minutos.

Um desenho seguro costuma usar:

  • access token JWT com duração curta;
  • refresh token aleatório, longo e de uso restrito ao endpoint de renovação;
  • armazenamento do hash do refresh token no servidor;
  • rotação: cada uso invalida o token anterior e emite outro;
  • revogação por sessão, dispositivo ou conta;
  • detecção de reutilização de um refresh token já rotacionado.

Não transforme um JWT com validade de meses em “refresh token” sem um mecanismo real de revogação. Se o requisito principal é logout imediato e controle centralizado de sessões, um identificador opaco armazenado no Redis pode ser mais simples. Veja o guia de Redis com Kotlin para cache e sessões antes de escolher a arquitetura.

Onde guardar o token no cliente?

A resposta depende da plataforma e do modelo de ameaça.

  • Aplicação web no navegador: cookies HttpOnly, Secure e com SameSite adequado reduzem exposição a JavaScript, mas exigem proteção contra CSRF conforme o fluxo. Guardar tokens no localStorage aumenta o impacto de XSS.
  • Android: use armazenamento protegido e evite logs, backups indevidos e parâmetros de URL. Não coloque segredo permanente dentro do APK.
  • Backend chamando backend: use um gerenciador de segredos e, quando possível, credenciais específicas do serviço em vez de reutilizar tokens de usuário.

Sempre use HTTPS. Um bearer token funciona como dinheiro ao portador: quem o captura pode usá-lo enquanto continuar válido.

Teste login e rotas privadas

O testApplication permite validar o fluxo sem subir um servidor externo. No teste, use uma chave exclusiva do ambiente de testes e um serviço de autenticação fake.

import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.client.request.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals

class AuthenticationTest {

    @Test
    fun `rota privada deve aceitar token emitido pelo login`() = testApplication {
        val settings = JwtSettings(
            secret = "segredo-apenas-para-teste-com-tamanho-adequado",
            issuer = "test-issuer",
            audience = "test-audience",
            realm = "test-realm"
        )

        application {
            configureSecurity(settings)
            configureAuthRoutes(FakeAuthService(), TokenService(settings), settings)
            configurePrivateRoutes()
        }

        val client = createClient {
            install(ContentNegotiation) { json() }
        }

        val login = client.post("/auth/login") {
            contentType(ContentType.Application.Json)
            setBody(LoginRequest("[email protected]", "senha-de-teste"))
        }

        assertEquals(HttpStatusCode.OK, login.status)
        val token = login.body<TokenResponse>().accessToken

        val me = client.get("/me") {
            bearerAuth(token)
        }

        assertEquals(HttpStatusCode.OK, me.status)
    }
}

Adicione também casos para:

  • token ausente;
  • assinatura inválida;
  • token expirado;
  • issuer ou audience incorretos;
  • claim obrigatória ausente;
  • role insuficiente;
  • credenciais inválidas no login;
  • refresh token revogado ou reutilizado.

Testes unitários verificam regras de token e permissão. Testes de integração devem cobrir o repositório de usuários, hash de senha e persistência de refresh tokens. Para esse segundo grupo, use Testcontainers com Kotlin quando a produção depende de PostgreSQL.

Erros comuns com Ktor e JWT

Colocar o segredo no repositório

Mesmo um repositório privado pode vazar em logs, forks, backups ou artefatos. Carregue o segredo no ambiente e planeje rotação. Se uma chave vazar, trocar o código não basta: revogue-a.

Validar somente a assinatura

Sem issuer e audience, um token válido de outro contexto pode ser aceito. Verifique também expiração, subject e claims necessárias.

Criar tokens sem expiração

Um bearer token roubado continua útil indefinidamente. Use access tokens curtos e um mecanismo separado para renovação.

Confiar em role antiga por muito tempo

Se a role está no JWT, uma mudança de permissão só aparece quando um novo token é emitido. Para operações críticas, consulte uma fonte atual ou use tokens muito curtos.

Registrar o header Authorization

Logs, ferramentas de observabilidade e proxies não devem armazenar tokens completos. Faça redaction de headers e nunca inclua credenciais em mensagens de erro.

Usar JWT para tudo

JWT é útil quando a validação local e a interoperabilidade justificam a complexidade. Sessões opacas continuam sendo uma opção excelente quando revogação imediata e simplicidade operacional são prioridades.

Misturar autenticação com regra de negócio

Handlers cheios de parsing de token e if (role == ...) ficam difíceis de testar. Extraia policy, principal e autorização para funções ou serviços pequenos.

Checklist de produção

Antes do deploy, verifique:

  • toda comunicação usa HTTPS;
  • a chave vem de secret manager ou variável protegida;
  • issuer, audience, subject e expiração são validados;
  • access token possui duração curta;
  • senhas usam Argon2id, bcrypt ou algoritmo apropriado;
  • login possui rate limiting e resposta genérica;
  • autorização verifica role, permissão ou ownership;
  • refresh tokens são armazenados como hash, rotacionados e revogáveis;
  • logs removem Authorization, cookies e credenciais;
  • relógios dos servidores estão sincronizados;
  • testes cobrem token expirado, inválido e permissão insuficiente;
  • existe procedimento de rotação e incidente de chave;
  • OpenAPI documenta o bearer scheme e respostas de erro;
  • métricas monitoram falhas de login sem expor dados pessoais.

Se sua equipe mantém serviços em linguagens diferentes, vale comparar o mesmo desenho no guia de autenticação e autorização em APIs Go. Os plugins mudam, mas expiração curta, separação entre identidade e permissão e proteção de segredos continuam válidas.

Perguntas frequentes

Ktor Authentication cria o endpoint de login automaticamente?

Não. O plugin valida credenciais ou tokens conforme o provider configurado, mas sua aplicação ainda precisa implementar o endpoint de login, consultar o usuário, comparar a senha e emitir o token.

JWT substitui o banco de usuários?

Não. O token representa uma autenticação já realizada. Cadastro, hash de senha, status da conta, troca de credenciais e regras de autorização continuam precisando de uma fonte confiável.

Posso invalidar um JWT antes da expiração?

Não de forma puramente stateless. Para revogação imediata, use validade curta, lista de revogação, versão de sessão, consulta de status ou tokens opacos armazenados no servidor.

Devo usar HMAC ou RSA/ECDSA?

HMAC é simples quando uma aplicação controlada assina e valida tokens. Algoritmos assimétricos facilitam distribuir apenas a chave pública para vários verificadores. A escolha depende da arquitetura, da rotação e de quem pode assinar tokens.

Onde colocar roles e permissions?

Claims pequenas e estáveis podem ficar no token. Permissões dinâmicas, ownership e decisões críticas devem ser verificadas no domínio ou em uma policy atualizada, especialmente quando mudanças precisam valer imediatamente.

Ktor JWT funciona com Android?

Sim, uma aplicação Android pode consumir a API e enviar o bearer token. O cuidado principal está no armazenamento seguro, no refresh, no tratamento de expiração e em não expor tokens em logs ou URLs.

Conclusão

Ktor Authentication com JWT oferece uma base enxuta para proteger APIs Kotlin, mas a implementação segura vai além de chamar authenticate. Configure um verifier que confira assinatura, issuer, audience e expiração; mantenha claims mínimas; emita access tokens curtos; e trate autorização como uma etapa separada.

Comece pelo fluxo mais simples que atende ao produto: login, token curto e rotas privadas. Depois, adicione roles, refresh token rotativo e revogação somente quando os requisitos justificarem. Evite copiar uma arquitetura de identidade corporativa para uma API pequena — e evite também levar para produção um tutorial sem hash de senha, HTTPS, testes e rotação de segredos.

Para completar o projeto, conecte este guia ao backend com Ktor, à serialização avançada com Kotlin, ao rate limiting de APIs e ao guia de CI/CD para Kotlin. Assim, autenticação deixa de ser um bloco isolado e passa a fazer parte de uma API observável, testável e operável.