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, comoHS256. - Payload: as claims, ou seja, os dados do token. Algumas são padronizadas:
sub(identificador do usuário),exp(expiração),iat(emissão) ejti(id único do token). Você pode adicionar as suas, comopapel. - 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.

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 olocalStorage. Mantenha o access token apenas em memória no front-end e entregue o refresh token em um cookiehttpOnly, 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-limitna rota/loginbarra 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.