Autenticação JWT em Rust com Axum: Guia Prático | Rust Brasil

Como implementar autenticação JWT em Rust com Axum: hash de senha com Argon2, geração e validação de tokens, extractor de usuário, refresh, erros e checklist.

Para autenticar uma API Rust com Axum, use a crate jsonwebtoken para emitir e validar tokens, argon2 para o hash das senhas e um extractor FromRequestParts para transformar o header Authorization: Bearer em um tipo de usuário autenticado que os handlers recebem como argumento. Esse é o desenho mais direto e mais usado em produção no ecossistema: o token carrega identidade e expiração, o extractor centraliza a validação e o compilador garante que nenhuma rota protegida esqueça a checagem.

Este guia mostra o caminho completo — cadastro, login, validação por requisição, autorização por papel, refresh token e erros — com as decisões de segurança explicadas em cada etapa.

Resposta rápida: as escolhas que importam

DecisãoRecomendaçãoPor quê
Crate de JWTjsonwebtokenpadrão de fato, integra com serde, suporta HS256 e RS256
Hash de senhaargon2 (ou bcrypt)resistente a GPU; nunca use SHA-256 puro
AlgoritmoHS256 para serviço único; RS256 para vários validadoresevita distribuir a chave de assinatura
Validade do access token5 a 15 minutoslimita a janela de um token vazado
Refresh tokenopaco, persistido em banco, rotacionadopermite logout e revogação reais
Transporte no browsercookie HttpOnly + Secure + SameSiteXSS não consegue ler o token
Ponto de validaçãoextractor FromRequestPartsuma implementação só, usada por tipo em todo handler
Segredovariável de ambiente / secret managernunca no Git, nunca hardcoded

Se você só quer o resumo: access token curto no header ou cookie, refresh token com estado no banco, extractor tipado no Axum, Argon2 para senhas e algoritmo fixado na validação. O resto deste artigo explica como montar isso sem armadilhas.

O que um JWT é — e o que ele não é

Um JSON Web Token é composto de três partes separadas por ponto: header, payload e assinatura. As duas primeiras são apenas Base64URL — qualquer pessoa com o token consegue ler o conteúdo. A assinatura garante que o payload não foi alterado, não que ele seja secreto.

Isso tem consequências práticas:

  • não coloque dados sensíveis nas claims (CPF, e-mail completo, permissões internas detalhadas);
  • não confie em nada que o cliente envie fora do token assinado;
  • lembre que um token válido continua válido até expirar, mesmo que o usuário seja bloqueado.

As claims registradas mais úteis são sub (identificador do usuário), exp (expiração), iat (emissão), iss (emissor) e jti (identificador único do token, útil para revogação).

Dependências

cargo add axum
cargo add tokio --features macros,rt-multi-thread
cargo add serde --features derive
cargo add serde_json
cargo add jsonwebtoken
cargo add argon2
cargo add password-hash --features rand_core
cargo add sqlx --features runtime-tokio-rustls,postgres,uuid,chrono,macros
cargo add chrono --features serde
cargo add uuid --features v4,serde
cargo add thiserror
cargo add tower-http --features cors,trace
cargo add tracing tracing-subscriber

As assinaturas exatas de jsonwebtoken e argon2 mudaram entre versões maiores. Depois de instalar, confirme com cargo doc --open ou com o README da versão fixada no seu Cargo.lock antes de copiar qualquer exemplo — inclusive os daqui.

Modelando as claims

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,     // id do usuário
    pub role: String,    // papel simples: "user" | "admin"
    pub exp: i64,        // expiração (timestamp unix)
    pub iat: i64,        // emissão
    pub jti: String,     // id do token
}

Mantenha o payload pequeno. Cada requisição carrega o token inteiro, e claims infladas viram custo de banda e superfície de erro. Papéis grosseiros (user, admin, billing) funcionam bem; permissões finas normalmente pertencem ao banco, consultadas no momento da ação.

Emitindo e validando o token

use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use uuid::Uuid;

pub struct JwtKeys {
    encoding: EncodingKey,
    decoding: DecodingKey,
}

impl JwtKeys {
    pub fn from_secret(secret: &[u8]) -> Self {
        Self {
            encoding: EncodingKey::from_secret(secret),
            decoding: DecodingKey::from_secret(secret),
        }
    }

    pub fn emitir(&self, user_id: &str, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
        let agora = Utc::now();
        let claims = Claims {
            sub: user_id.to_string(),
            role: role.to_string(),
            iat: agora.timestamp(),
            exp: (agora + Duration::minutes(15)).timestamp(),
            jti: Uuid::new_v4().to_string(),
        };

        encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)
    }

    pub fn validar(&self, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
        let mut validation = Validation::new(Algorithm::HS256);
        validation.leeway = 5; // tolerância de relógio, em segundos

        decode::<Claims>(token, &self.decoding, &validation).map(|dados| dados.claims)
    }
}

Três detalhes salvam projetos aqui:

  1. Fixe o algoritmo. Validation::new(Algorithm::HS256) impede que um token forjado peça outro algoritmo. Aceitar o algoritmo declarado pelo cliente é uma classe clássica de vulnerabilidade.
  2. Nunca aceite alg: none. A crate já rejeita por padrão; não reescreva essa validação.
  3. Carregue o segredo do ambiente. Em desenvolvimento, dotenvy; em produção, o secret manager da sua plataforma. Um segredo HS256 deve ter pelo menos 32 bytes aleatórios (openssl rand -base64 48).

Hash de senha com Argon2

Senha nunca é guardada, nem cifrada de forma reversível: guarda-se um hash lento e salgado.

use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};

pub fn hash_senha(senha: &str) -> Result<String, argon2::password_hash::Error> {
    let salt = SaltString::generate(&mut OsRng);
    Ok(Argon2::default()
        .hash_password(senha.as_bytes(), &salt)?
        .to_string())
}

pub fn verificar_senha(senha: &str, hash_armazenado: &str) -> bool {
    match PasswordHash::new(hash_armazenado) {
        Ok(parsed) => Argon2::default()
            .verify_password(senha.as_bytes(), &parsed)
            .is_ok(),
        Err(_) => false,
    }
}

Argon2 é propositalmente custoso em CPU e memória. Em um runtime assíncrono, isso significa bloquear o executor: uma verificação pode levar dezenas ou centenas de milissegundos. Rode o hash em tokio::task::spawn_blocking para não travar as outras tarefas do runtime.

let hash = hash_armazenado.clone();
let senha = payload.senha.clone();
let ok = tokio::task::spawn_blocking(move || verificar_senha(&senha, &hash))
    .await
    .map_err(|_| ApiError::Interno)?;

Esse é um dos erros mais comuns em APIs Rust: código correto do ponto de vista criptográfico que derruba a latência de toda a aplicação sob carga. Se o assunto de concorrência ainda não está confortável, revise o tutorial de concorrência em Rust.

O extractor de usuário autenticado

Este é o coração da integração com o Axum. Em vez de chamar uma função de verificação no início de cada handler — algo que se esquece —, você cria um tipo que só pode existir se o token for válido.

use axum::{
    extract::FromRequestParts,
    http::{header::AUTHORIZATION, request::Parts},
};

pub struct UsuarioAutenticado {
    pub id: String,
    pub role: String,
}

impl<S> FromRequestParts<S> for UsuarioAutenticado
where
    AppState: axum::extract::FromRef<S>,
    S: Send + Sync,
{
    type Rejection = ApiError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let state = AppState::from_ref(state);

        let token = parts
            .headers
            .get(AUTHORIZATION)
            .and_then(|valor| valor.to_str().ok())
            .and_then(|valor| valor.strip_prefix("Bearer "))
            .ok_or(ApiError::NaoAutenticado)?;

        let claims = state
            .keys
            .validar(token.trim())
            .map_err(|_| ApiError::NaoAutenticado)?;

        Ok(UsuarioAutenticado {
            id: claims.sub,
            role: claims.role,
        })
    }
}

Agora um handler protegido é simplesmente:

async fn perfil(usuario: UsuarioAutenticado) -> Json<serde_json::Value> {
    Json(serde_json::json!({ "id": usuario.id, "role": usuario.role }))
}

E um handler público não declara o argumento. A proteção passa a ser visível na assinatura da função — revisão de código e o próprio compilador ajudam a não esquecer nenhuma rota. Se você ainda não montou uma API com Axum, comece pelo tutorial de API REST com Axum e pelo guia do Axum no ecossistema.

A assinatura de FromRequestParts mudou entre versões do Axum (com e sem #[async_trait]). Confira a versão que você instalou antes de copiar.

Autorização por papel

Autenticação responde “quem é você”; autorização responde “você pode fazer isso”. Separe as duas.

pub struct Admin(pub UsuarioAutenticado);

impl<S> FromRequestParts<S> for Admin
where
    AppState: axum::extract::FromRef<S>,
    S: Send + Sync,
{
    type Rejection = ApiError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let usuario = UsuarioAutenticado::from_request_parts(parts, state).await?;

        if usuario.role != "admin" {
            return Err(ApiError::SemPermissao);
        }

        Ok(Admin(usuario))
    }
}

Um handler que exige Admin não compila sem a checagem. Para regras que dependem de dados (“o dono deste recurso”), a verificação precisa ir ao banco dentro do handler — o token não sabe quem é dono do quê.

Rotas de cadastro e login

use axum::{routing::post, Json, Router};

#[derive(Deserialize)]
struct LoginPayload {
    email: String,
    senha: String,
}

async fn login(
    State(state): State<AppState>,
    Json(payload): Json<LoginPayload>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let usuario = sqlx::query!(
        "SELECT id, role, senha_hash FROM usuarios WHERE email = $1",
        payload.email
    )
    .fetch_optional(&state.db)
    .await
    .map_err(|_| ApiError::Interno)?;

    // Mesma resposta para e-mail inexistente e senha errada
    let usuario = usuario.ok_or(ApiError::CredenciaisInvalidas)?;

    let hash = usuario.senha_hash.clone();
    let senha = payload.senha.clone();
    let ok = tokio::task::spawn_blocking(move || verificar_senha(&senha, &hash))
        .await
        .map_err(|_| ApiError::Interno)?;

    if !ok {
        return Err(ApiError::CredenciaisInvalidas);
    }

    let token = state
        .keys
        .emitir(&usuario.id.to_string(), &usuario.role)
        .map_err(|_| ApiError::Interno)?;

    Ok(Json(serde_json::json!({ "access_token": token, "token_type": "Bearer" })))
}

pub fn rotas() -> Router<AppState> {
    Router::new()
        .route("/auth/registrar", post(registrar))
        .route("/auth/login", post(login))
}

Note a resposta idêntica para “usuário não existe” e “senha incorreta”. Mensagens diferentes permitem enumeração de contas: um atacante descobre quais e-mails estão cadastrados. Pelo mesmo motivo, considere executar o hash mesmo quando o usuário não existe, para não vazar a diferença pelo tempo de resposta.

Valide o payload de entrada antes de tocar no banco — tamanho mínimo de senha, formato de e-mail, limites de string. O artigo sobre validação de dados com validator e garde cobre as opções do ecossistema. Para as queries e o schema, veja o guia de SQLx e o tutorial de Rust com PostgreSQL.

Erros que não vazam informação

use axum::{http::StatusCode, response::{IntoResponse, Response}};

#[derive(Debug, thiserror::Error)]
pub enum ApiError {
    #[error("não autenticado")]
    NaoAutenticado,
    #[error("sem permissão")]
    SemPermissao,
    #[error("credenciais inválidas")]
    CredenciaisInvalidas,
    #[error("erro interno")]
    Interno,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, mensagem) = match self {
            ApiError::NaoAutenticado => (StatusCode::UNAUTHORIZED, "token ausente ou inválido"),
            ApiError::SemPermissao => (StatusCode::FORBIDDEN, "acesso negado"),
            ApiError::CredenciaisInvalidas => (StatusCode::UNAUTHORIZED, "credenciais inválidas"),
            ApiError::Interno => (StatusCode::INTERNAL_SERVER_ERROR, "erro interno"),
        };

        (status, Json(serde_json::json!({ "erro": mensagem }))).into_response()
    }
}

Registre o detalhe técnico com tracing e devolva a mensagem genérica ao cliente. Diferenciar “token expirado” de “assinatura inválida” na resposta HTTP entrega informação útil a quem está atacando. Para a modelagem de erros em geral, consulte o guia de thiserror e anyhow.

Refresh token: o que torna o logout possível

Access tokens curtos resolvem a exposição, mas obrigariam o usuário a fazer login a cada 15 minutos. A solução é um segundo token, com natureza diferente:

Access tokenRefresh token
FormatoJWT assinadovalor opaco aleatório
Validade5–15 minutosdias ou semanas
Armazenamento no servidornenhumhash persistido no banco
Usotoda requisiçãosó no endpoint /auth/refresh
Revogávelnão (até expirar)sim, apagando a linha

O fluxo é: /auth/refresh recebe o refresh token, procura o hash no banco, confere validade e revogação, emite um novo par e invalida o antigo (rotação). Se um refresh token já usado reaparecer, é sinal de roubo — revogue toda a família de tokens daquele usuário.

Guarde o refresh token em cookie HttpOnly, Secure, SameSite=Strict e com Path restrito ao endpoint de refresh. Guarde apenas o hash no banco, nunca o valor em claro: um dump de banco não deve virar acesso às contas.

Middleware, CORS e rate limiting

O extractor cobre a validação por rota. Algumas proteções, porém, são transversais e vivem como camadas tower:

use tower_http::{cors::CorsLayer, trace::TraceLayer};

let app = Router::new()
    .merge(auth::rotas())
    .route("/perfil", get(perfil))
    .layer(TraceLayer::new_for_http())
    .layer(cors_restrito())
    .with_state(state);

Pontos de atenção:

  • CORS com origens explícitas. Any combinado com credenciais é uma configuração perigosa e frequentemente rejeitada pelo navegador.
  • Rate limiting no /auth/login. Sem limite por IP e por conta, a rota vira alvo de força bruta. tower tem camadas para isso; veja o artigo sobre serviços resilientes com Tower e Axum.
  • Limite de tamanho de corpo, para não aceitar payloads absurdos na rota pública.
  • TLS obrigatório. Token em HTTP puro é token exposto; veja rustls em produção.

Para documentar as rotas protegidas com o esquema bearerAuth, o guia de utoipa e Swagger UI mostra a anotação necessária.

Testando a autenticação

Testes de autenticação são baratos e pegam regressões graves:

#[tokio::test]
async fn rejeita_requisicao_sem_token() {
    let app = app_de_teste().await;

    let resposta = app
        .oneshot(Request::builder().uri("/perfil").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(resposta.status(), StatusCode::UNAUTHORIZED);
}

Cubra pelo menos: sem header; header sem Bearer; token com assinatura de outra chave; token expirado; papel insuficiente em rota de admin; login com senha errada; refresh token já usado. Se o Router for construído por uma função reutilizável, esses testes rodam sem subir servidor nem infraestrutura — o padrão descrito no guia de testes em Rust.

Quando não usar JWT

JWT não é obrigatório. Para um monólito com sessões de navegador, uma sessão em cookie com estado no servidor é mais simples, revogável por natureza e evita todo o aparato de refresh. JWT brilha quando há vários serviços validando tokens sem consultar um banco central, ou clientes que não são navegadores.

Se a sua aplicação tem um único backend e um único frontend, avalie honestamente se a complexidade extra do par access/refresh está pagando por si mesma. E se o requisito envolve login social, SSO corporativo ou MFA, considere um provedor de identidade dedicado em vez de reimplementar OAuth2 e OIDC.

Checklist de autenticação antes de produção

  • segredo JWT com 32+ bytes aleatórios, vindo do ambiente e fora do Git;
  • algoritmo fixado na Validation;
  • exp sempre presente e curto no access token;
  • senhas com Argon2 ou bcrypt, jamais SHA puro;
  • hash de senha em spawn_blocking;
  • respostas idênticas para e-mail inexistente e senha errada;
  • rate limiting em /auth/login e /auth/registrar;
  • refresh token opaco, com hash no banco e rotação;
  • logout revoga o refresh token;
  • cookies HttpOnly, Secure, SameSite quando o cliente é navegador;
  • CORS com origens explícitas;
  • TLS em todo ambiente que não seja localhost;
  • tokens e senhas nunca aparecem em logs;
  • rotas protegidas declaram o extractor na assinatura;
  • testes para token ausente, inválido, expirado e papel insuficiente;
  • plano de rotação da chave de assinatura.

Perguntas frequentes sobre JWT em Rust

Qual crate usar para JWT em Rust?

jsonwebtoken é a escolha padrão: cobre HS256 e RS256, integra com serde e expõe a validação de forma explícita. Para hash de senha, use argon2 ou bcrypt — são problemas distintos e não devem compartilhar a mesma biblioteca nem a mesma chave.

Como validar o token em cada requisição no Axum?

Com um extractor FromRequestParts que lê Authorization, remove o prefixo Bearer , decodifica com a chave e as validações configuradas e devolve as claims. Handlers protegidos recebem esse tipo como argumento, e o Axum rejeita a requisição antes do corpo do handler executar.

Prefira cookie HttpOnly, Secure e SameSite em aplicações web: um XSS não consegue ler o valor. localStorage é acessível a qualquer script da página. Para clientes nativos e chamadas servidor a servidor, o header Authorization continua sendo a opção adequada.

Como fazer logout com JWT?

Um JWT assinado permanece válido até exp, então logout exige estado: mantenha access tokens curtos e revogue o refresh token persistido no banco. Para invalidar um access token antes do vencimento, mantenha uma lista de jti revogados em Redis, consultada no extractor.

HS256 ou RS256?

HS256 usa segredo compartilhado e basta quando o mesmo serviço emite e valida. RS256 usa par de chaves e é preferível quando vários serviços validam tokens de um emissor único, pois só ele guarda a chave privada. Em qualquer caso, fixe o algoritmo esperado na validação.

Preciso de banco de dados para usar JWT?

Para validar o access token, não — a assinatura basta. Mas você precisará de banco para as credenciais, para os refresh tokens e para qualquer revogação. Na prática, uma API real com JWT tem estado; o que o JWT evita é uma consulta ao banco a cada requisição autenticada.

Conclusão

Autenticação JWT em Rust com Axum é menos sobre a crate e mais sobre o desenho. As decisões que sustentam o sistema são: Argon2 fora do runtime assíncrono, algoritmo fixado na validação, access token curto, refresh token com estado e rotação, erros genéricos na resposta e — o ponto onde o Rust realmente ajuda — a proteção expressa como tipo na assinatura do handler.

Esse último detalhe é o que diferencia a abordagem. Em muitas linguagens, esquecer o middleware em uma rota é um bug silencioso descoberto em produção. Com um extractor tipado, a rota protegida declara sua exigência e o compilador cobra. Aproveite isso: modele UsuarioAutenticado, Admin e outros papéis como tipos distintos em vez de checar strings dentro dos handlers.

Para continuar, monte a API completa seguindo o tutorial de API REST com Axum, publique o resultado com o guia de deploy no Shuttle e use o projeto no portfólio — autenticação bem feita é um dos itens mais avaliados nas vagas Rust e na trilha de backend web.