Autenticação JWT em Node.js: login com access token e refresh token

Autenticação JWT em Node.js é o caminho mais comum para proteger uma API REST consumida por um front-end em React, um app mobile ou outro serviço. O usuário faz login uma vez, recebe um token assinado e passa a enviá-lo em cada requisição; o servidor confere a assinatura e libera o acesso sem consultar uma sessão em memória.

O problema é que muitos tutoriais param no jwt.sign() e deixam de fora o que define a segurança do sistema: um access token de vida curta, um refresh token que rotaciona a cada uso, a revogação no logout e o cuidado com o lugar em que o token fica guardado no navegador.

Este tutorial monta esse fluxo completo com Express, jsonwebtoken e bcrypt: rota de login, middleware de autorização, rotação de refresh token e logout, além dos erros que mais aparecem em produção.

Como o JWT funciona: header, payload e assinatura

Um JSON Web Token é uma string com três partes separadas por ponto, cada uma codificada em Base64URL: header.payload.assinatura.

  • Header: informa o tipo (JWT) e o algoritmo de assinatura, como HS256.
  • Payload: as claims, ou seja, os dados do token. Algumas são padronizadas: sub (identificador do usuário), exp (expiração), iat (emissão) e jti (id único do token). Você pode adicionar as suas, como papel.
  • Assinatura: o resultado de aplicar o algoritmo sobre header e payload usando um segredo que só o servidor conhece.

O ponto que mais confunde iniciantes: o payload não é criptografado, apenas codificado. Qualquer pessoa com o token consegue ler o conteúdo. O que a assinatura garante é que ninguém alterou esses dados sem o segredo. Por isso o token nunca deve carregar senha, CPF ou qualquer dado sensível.

Como o servidor valida o token só com a assinatura, ele não precisa guardar nada por sessão. Essa é a vantagem (escala sem estado) e também a limitação: um token válido continua válido até expirar, mesmo que o usuário tenha feito logout. A solução para esse dilema é a dupla access token curto + refresh token longo e revogável.

Setup do projeto de autenticação JWT em Node.js: Express, jsonwebtoken e bcrypt

Crie o projeto, instale as dependências e habilite módulos ES no package.json ("type": "module") para usar import e await no topo do arquivo:

mkdir api-jwt && cd api-jwt
npm init -y
npm install express jsonwebtoken bcrypt dotenv

Cada pacote tem um papel: o express cuida das rotas, o jsonwebtoken assina e verifica tokens, o bcrypt gera e compara hashes de senha, e o dotenv carrega as configurações de um arquivo .env.

Os segredos do JWT são a parte mais crítica da configuração. Gere strings longas e aleatórias, uma para cada tipo de token, e nunca as coloque no código. Se ainda não tem o hábito de separar configuração de código, o guia sobre variáveis de ambiente e arquivo .env explica como organizar isso em desenvolvimento e produção.

# gera um segredo aleatório de 64 bytes em hexadecimal
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# .env (adicione ao .gitignore)
JWT_ACCESS_SECRET=cole-aqui-o-primeiro-segredo-gerado
JWT_REFRESH_SECRET=cole-aqui-o-segundo-segredo-diferente
ACCESS_TTL=15m
REFRESH_TTL=7d
PORT=3000

Usar dois segredos diferentes impede que um refresh token seja aceito como access token (ou o contrário), mesmo que alguém tente reaproveitar a string em outra rota.

Rota de login: validar a senha e emitir access + refresh token

O login faz três coisas: localiza o usuário, compara a senha com o hash guardado e, se tudo estiver certo, emite os dois tokens. O exemplo usa um array no lugar do banco de dados para manter o foco no fluxo; em produção, troque pelo seu ORM ou driver.

// server.js
import 'dotenv/config';
import express from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';

const app = express();
app.use(express.json());

// Simulação do banco: a senha é guardada apenas como hash
const usuarios = [
  { id: 1, email: 'ana@exemplo.com', papel: 'admin', senhaHash: await bcrypt.hash('segredo123', 12) },
];
const hashFalso = await bcrypt.hash('qualquer-coisa', 12); // usado quando o e-mail não existe
const refreshTokensAtivos = new Map(); // jti -> { userId }  (em produção: tabela ou Redis)

function gerarAccessToken(usuario) {
  return jwt.sign(
    { sub: String(usuario.id), papel: usuario.papel },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: process.env.ACCESS_TTL }
  );
}

function gerarRefreshToken(usuario) {
  const jti = crypto.randomUUID(); // identificador único deste refresh token
  refreshTokensAtivos.set(jti, { userId: usuario.id });
  return jwt.sign(
    { sub: String(usuario.id), jti },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: process.env.REFRESH_TTL }
  );
}

app.post('/login', async (req, res) => {
  const { email, senha } = req.body ?? {};
  const usuario = usuarios.find((u) => u.email === email);

  // Compara sempre, mesmo sem usuário, para o tempo de resposta não revelar e-mails cadastrados
  const senhaConfere = await bcrypt.compare(senha ?? '', usuario?.senhaHash ?? hashFalso);

  if (!usuario || !senhaConfere) {
    return res.status(401).json({ erro: 'Credenciais inválidas' });
  }

  res.json({
    accessToken: gerarAccessToken(usuario),
    refreshToken: gerarRefreshToken(usuario),
  });
});

app.listen(process.env.PORT, () => console.log(`API em http://localhost:${process.env.PORT}`));

Dois detalhes fazem diferença na segurança. A mensagem de erro é a mesma para e-mail inexistente e senha errada, e o bcrypt.compare roda nos dois casos, para que o tempo de resposta não entregue quais e-mails existem. O custo 12 do bcrypt torna o hash lento de propósito, o que inviabiliza ataques de força bruta sobre um banco vazado.

Teste com o curl: curl -X POST localhost:3000/login -H "Content-Type: application/json" -d '{"email":"ana@exemplo.com","senha":"segredo123"}'. A resposta traz os dois tokens; guarde o access para as próximas chamadas.

Infográfico do fluxo de autenticação JWT em Node.js: login validado com bcrypt, access token curto de 15 minutos, refresh token longo e rotativo, middleware que confere a assinatura e revogação do refresh token no logout.

Middleware de autorização

Toda rota protegida passa por um middleware que lê o header Authorization: Bearer <token>, verifica a assinatura e a expiração e anexa os dados do usuário ao req. Se qualquer etapa falhar, a resposta é 401 e a rota nem chega a executar.

function autenticar(req, res, next) {
  const [tipo, token] = (req.headers.authorization ?? '').split(' ');

  if (tipo !== 'Bearer' || !token) {
    return res.status(401).json({ erro: 'Token ausente' });
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] });
    req.usuario = { id: Number(payload.sub), papel: payload.papel };
    next();
  } catch (erro) {
    const expirado = erro.name === 'TokenExpiredError';
    return res.status(401).json({ erro: expirado ? 'Token expirado' : 'Token inválido' });
  }
}

// Autorização por papel: só quem tem o papel exigido passa
const exigirPapel = (papel) => (req, res, next) =>
  req.usuario.papel === papel ? next() : res.status(403).json({ erro: 'Sem permissão' });

app.get('/perfil', autenticar, (req, res) => res.json(req.usuario));
app.delete('/usuarios/:id', autenticar, exigirPapel('admin'), (req, res) => res.status(204).end());

Repare na opção algorithms: ['HS256']. Ela fixa o algoritmo aceito e fecha a porta para ataques em que o cliente altera o header do token para none ou troca o algoritmo. Diferenciar “expirado” de “inválido” também ajuda o front-end: no primeiro caso ele deve chamar a rota de refresh; no segundo, mandar o usuário para o login.

A distinção entre 401 e 403 importa. 401 significa “não sei quem você é”; 403 significa “sei quem você é, mas você não pode fazer isso”. Misturar os dois confunde o cliente e dificulta o diagnóstico.

Rotação de refresh token e logout

O access token expira em 15 minutos. Para o usuário não precisar logar de novo a cada expiração, o cliente envia o refresh token para a rota /refresh e recebe um novo par. A rotação significa que o refresh antigo é invalidado no mesmo instante: cada refresh token serve para uma única troca.

app.post('/refresh', (req, res) => {
  const { refreshToken } = req.body ?? {};
  let payload;

  try {
    payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
  } catch {
    return res.status(401).json({ erro: 'Refresh token inválido ou expirado' });
  }

  const registro = refreshTokensAtivos.get(payload.jti);

  if (!registro) {
    // Token válido, mas já usado ou revogado: sinal de reuso. Derruba todas as sessões do usuário.
    for (const [jti, r] of refreshTokensAtivos) {
      if (r.userId === Number(payload.sub)) refreshTokensAtivos.delete(jti);
    }
    return res.status(401).json({ erro: 'Refresh token reutilizado' });
  }

  refreshTokensAtivos.delete(payload.jti); // rotação: o token antigo morre aqui
  const usuario = usuarios.find((u) => u.id === registro.userId);

  res.json({
    accessToken: gerarAccessToken(usuario),
    refreshToken: gerarRefreshToken(usuario),
  });
});

app.post('/logout', (req, res) => {
  const { refreshToken } = req.body ?? {};
  try {
    const { jti } = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, { ignoreExpiration: true });
    refreshTokensAtivos.delete(jti); // revoga; o access expira sozinho em minutos
  } catch {
    // token inválido: não há o que revogar
  }
  res.status(204).end();
});

A detecção de reuso é o que torna a rotação valiosa. Se um atacante roubar um refresh token e usá-lo, o usuário legítimo vai tentar usar o mesmo token depois e cair no caso “já usado”. Nesse momento todas as sessões são derrubadas, e ambos precisam logar de novo, o que limita o estrago.

O Map em memória é só para o exemplo: ele some ao reiniciar o processo e não funciona com mais de uma instância. Em produção, guarde os refresh tokens ativos em uma tabela (jti, user_id, expira_em) ou no Redis com TTL igual ao do token.

Erros comuns e segurança na autenticação JWT em Node.js

A maior parte dos incidentes com JWT não vem da biblioteca, e sim de decisões ao redor dela. Esta é a lista do que mais aparece em revisões de código:

  • Access token longo demais. Um token de 30 dias sem possibilidade de revogação é uma sessão eterna nas mãos de quem o roubar. Fique entre 5 e 15 minutos e deixe a duração maior para o refresh.
  • Segredo fraco ou versionado. Segredo curto é quebrável por força bruta offline; segredo no Git é público. Use os 64 bytes aleatórios e as variáveis de ambiente da seção de setup. O artigo sobre segurança do Bearer Token com variáveis de ambiente e cofres de senhas mostra como fazer isso no deploy.
  • Token em localStorage. Qualquer script injetado na página (XSS) consegue ler o localStorage. Mantenha o access token apenas em memória no front-end e entregue o refresh token em um cookie httpOnly, que o JavaScript da página não acessa.
  • HTTP sem TLS. Tokens trafegando sem HTTPS podem ser capturados na rede. Em produção, HTTPS não é opcional.
  • Dados sensíveis no payload. Lembre que o payload é legível por quem tem o token.
  • Sem limite de tentativas no login. Um express-rate-limit na rota /login barra ataques de força bruta e enumeração de contas.

Para entregar o refresh token em cookie, substitua o campo no JSON por res.cookie() e leia o valor com o pacote cookie-parser na rota de refresh:

res.cookie('refreshToken', gerarRefreshToken(usuario), {
  httpOnly: true,      // inacessível ao JavaScript da página
  secure: true,        // só trafega por HTTPS
  sameSite: 'strict',  // não é enviado em requisições de outros sites
  path: '/refresh',    // só vai para a rota que precisa dele
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

Por fim, senha e JWT provam apenas que alguém conhece a senha. Para contas com dados sensíveis ou permissões administrativas, adicione uma segunda camada logo após o login com autenticação de dois fatores (2FA): o usuário valida a senha, recebe um código por SMS, WhatsApp ou app autenticador e só então os tokens são emitidos.

Perguntas frequentes

JWT substitui a sessão no servidor?

Para o access token, sim: a assinatura dispensa consulta a qualquer armazenamento. O refresh token com rotação, porém, precisa de estado (a lista de jti ativos), então na prática você mantém um registro leve por sessão. É uma troca consciente: escala no caminho quente e controle no caminho de renovação.

Onde guardar o token no front-end?

Access token em memória (uma variável no estado da aplicação) e refresh token em cookie httpOnly, secure e sameSite. Evite localStorage e sessionStorage para qualquer um dos dois, porque um XSS lê esses espaços sem dificuldade.

Quanto tempo o access token deve durar?

Entre 5 e 15 minutos para a maior parte das aplicações. Esse intervalo limita o tempo de vida de um token roubado sem incomodar o usuário, já que a renovação pelo refresh acontece em segundo plano.

Como invalidar um access token antes de expirar?

Sem estado, não há como. As alternativas são manter o TTL curto, usar uma lista de bloqueio no Redis consultada pelo middleware em operações críticas ou, em emergência, trocar o segredo, o que derruba todos os usuários de uma vez.

HS256 ou RS256?

HS256 usa um segredo compartilhado e resolve bem quando o mesmo serviço emite e valida os tokens. RS256 usa um par de chaves: o serviço de autenticação assina com a privada e qualquer outro serviço valida com a pública, sem conhecer o segredo. Em arquiteturas com vários serviços, prefira RS256.

Próximos passos

Com login, middleware, rotação e logout funcionando, a autenticação JWT em Node.js está pronta para sair do exemplo e entrar no seu projeto.

Troque o array de usuários e o Map por tabelas no banco, ative o rate limit na rota de login, escreva testes para os caminhos de erro (token expirado, reuso de refresh, papel insuficiente) e avalie o 2FA para as contas que mais importam. Cada um desses passos é pequeno, e juntos eles separam um exemplo de uma API que você pode colocar em produção.