Autenticação com JWT no Express: login e rota protegida
Gerar o token no login, ler o header Authorization no middleware, proteger rotas e entender o que o JWT guarda — e o que ele não protege.
JWT, sigla de JSON Web Token, é um formato de token assinado que o cliente pode apresentar para provar uma autenticação anterior. No login, o servidor confere e-mail e senha, assina um conjunto pequeno de dados e devolve a string. Nas próximas requisições, um middleware verifica a assinatura antes de liberar a rota.
Pense num crachá emitido pela clínica. O payload é o texto impresso — identidade
e papel —, a assinatura é a marca que permite detectar adulteração e a expiração
é a validade. O porteiro não precisa telefonar para a recepção a cada passagem;
ele confere o crachá. De volta ao comportamento técnico: o middleware executa
jwt.verify, valida assinatura e tempo, coloca a identidade em req.user e
deixa a rota decidir a autorização. Como o texto do crachá é visível, o payload
não deve conter segredo.
Todos os exemplos rodam na API da Pata Firme, uma clínica veterinária com dois tipos de usuário: a veterinária Helena, que vê prontuário, e o recepcionista Marcos, que só vê a agenda do dia. A base é uma API Express comum, do tipo que você monta em subir a primeira rota no Express.
As três partes separadas por ponto
Um JWT é uma string com dois pontos no meio. Cada ponto separa uma parte.
import jwt from 'jsonwebtoken';
const token = jwt.sign(
{ sub: 7, nome: 'Helena Prado', papel: 'veterinario' },
'segredo-da-clinica',
{ expiresIn: '15m' },
);
console.log(token);
console.log('partes:', token.split('.').length);A primeira parte diz como o token foi assinado. A segunda são os dados. A terceira é a assinatura das duas primeiras, feita com o segredo do servidor.
Abrindo um token real: o payload não é segredo
Aquele monte de letras parece cifrado. Não é. É base64url, uma codificação —
e codificação se desfaz sem chave nenhuma. Guarde num $TOKEN o token que a API
da Pata Firme devolve no login (a rota vem na próxima seção) e abra as duas
primeiras partes no terminal:
node -p "Buffer.from('$TOKEN'.split('.')[0], 'base64url').toString()"
node -p "Buffer.from('$TOKEN'.split('.')[1], 'base64url').toString()"Nenhum segredo foi usado ali. Qualquer pessoa que interceptar o token lê o conteúdo inteiro. O que a assinatura garante é outra coisa: que ninguém alterou esse conteúdo.
Os dois números são datas em segundos desde 1970. iat é quando o token foi
emitido, exp é quando ele morre:
const p = JSON.parse(Buffer.from(token.split('.')[1], 'base64url'));
console.log('iat', new Date(p.iat * 1000).toISOString());
console.log('exp', new Date(p.exp * 1000).toISOString());Quinze minutos, exatamente o expiresIn: '15m' da assinatura.
A rota de login: conferir a credencial e assinar
Login é uma rota comum. Ela compara a senha com o hash guardado — o assunto de hash de senha com bcrypt — e só então assina.
app.post('/login', async (req, res) => {
const { email, senha } = req.body ?? {};
const usuario = usuarios.find((u) => u.email === email);
const confere = usuario && (await bcrypt.compare(senha, usuario.senhaHash));
if (!confere) {
return res.status(401).json({ erro: 'E-mail ou senha inválidos' });
}
const token = jwt.sign(
{ sub: usuario.id, nome: usuario.nome, papel: usuario.papel },
process.env.JWT_SECRET,
{ expiresIn: '15m' },
);
res.json({ token, expiraEm: 900 });
});Repare no confere: a mensagem é a mesma para e-mail inexistente e para senha
errada. Dizer “esse e-mail não existe” entrega para o atacante quais contas são
reais.
curl -s -i -X POST http://localhost:3100/login \
-H 'Content-Type: application/json' \
-d '{"email":"helena@patafirme.vet","senha":"raiox2025"}'{“erro”:“E-mail ou senha inválidos”}
Com a senha certa, vem o crachá:
curl -s -X POST http://localhost:3100/login \
-H 'Content-Type: application/json' \
-d '{"email":"helena@patafirme.vet","senha":"raio-x-2026"}'O middleware que lê o Authorization: Bearer
O cliente devolve o token num header padronizado: a palavra Bearer, um espaço,
e o token. Quem lê isso é um middleware do Express,
registrado antes das rotas que precisam de gente logada.
export function autenticar(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ erro: 'Token ausente' });
}
const token = header.slice(7);
try {
req.usuario = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (erro) {
if (erro.name === 'TokenExpiredError') {
return res.status(401).json({ erro: 'Token expirado', expirouEm: erro.expiredAt });
}
return res.status(401).json({ erro: 'Token inválido' });
}
}Três detalhes que importam. slice(7) corta exatamente Bearer — sete
caracteres. jwt.verify devolve o payload já validado, e é ele que vira
req.usuario para as rotas seguintes. E o try/catch é obrigatório: verify
não devolve false quando falha, ele lança.
Agora é só pendurar o middleware na rota:
app.get('/agenda', autenticar, (req, res) => {
res.json({ para: req.usuario.nome, consultas: agenda });
});Sem header nenhum:
curl -s -i http://localhost:3100/agendaCom o token da Helena:
curl -s http://localhost:3100/agenda -H "Authorization: Bearer $TOKEN"E o erro que come uma tarde inteira de quem está começando: mandar o token sem a
palavra Bearer na frente.
curl -s http://localhost:3100/agenda -H "Authorization: $TOKEN"O token estava perfeito. O header é que não estava no formato. Por isso a mensagem diz “ausente” e não “inválido” — o middleware nem chegou a olhar para o token.
Trocar o papel no payload e ver a assinatura reprovar
Se o payload é legível, o Marcos da recepção pode abrir o token dele, trocar
papel: 'recepcao' por papel: 'veterinario' e remontar a string. Vamos fazer
isso de verdade:
const [cabecalho, payload, assinatura] = tokenDoMarcos.split('.');
const dados = JSON.parse(Buffer.from(payload, 'base64url').toString());
dados.papel = 'veterinario';
const payloadFalso = Buffer.from(JSON.stringify(dados)).toString('base64url');
const tokenFalso = `${cabecalho}.${payloadFalso}.${assinatura}`;
console.log(Buffer.from(tokenFalso.split('.')[1], 'base64url').toString());O payload agora mente. Mandando esse token para a rota de prontuário:
curl -s -i http://localhost:3100/prontuarios -H "Authorization: Bearer $FALSO"A assinatura é um HMAC do cabeçalho e do payload juntos, calculado com o segredo. Mudou uma vírgula no payload, o HMAC muda inteiro — e o Marcos não tem o segredo para recalcular. Rodando o mesmo teste fora da API, o erro tem nome:
try {
jwt.verify(tokenFalso, process.env.JWT_SECRET);
} catch (erro) {
console.log(erro.name + ':', erro.message);
}Um truque antigo é mandar alg: "none" no cabeçalho e um token sem assinatura
nenhuma, apostando que a biblioteca aceite. A versão 9 do jsonwebtoken não cai
nessa:
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const semAssinatura = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: 7, papel: 'veterinario' })}.`;
try {
jwt.verify(semAssinatura, process.env.JWT_SECRET);
} catch (erro) {
console.log(erro.name + ':', erro.message);
}401 ou 403: quem você é e o que você pode
O token diz quem você é. Não diz o que você pode. A permissão é uma segunda checagem, e o status code correto muda:
export function exigirPapel(...papeis) {
return (req, res, next) => {
if (!papeis.includes(req.usuario.papel)) {
return res.status(403).json({ erro: 'Seu perfil não tem acesso a este recurso' });
}
next();
};
}
app.get('/prontuarios', autenticar, exigirPapel('veterinario'), (req, res) => {
res.json({ prontuarios });
});A ordem é obrigatória: exigirPapel lê req.usuario, que só existe depois que
autenticar rodou. Com o token do Marcos, a agenda abre:
curl -s -i http://localhost:3100/agenda -H "Authorization: Bearer $MARCOS" | head -1E o prontuário, não:
curl -s -i http://localhost:3100/prontuarios -H "Authorization: Bearer $MARCOS"{“erro”:“Seu perfil não tem acesso a este recurso”}
Com o token da Helena, o mesmo prontuário responde:
| situação | status | o que o front faz |
|---|---|---|
| header ausente ou mal formado | 401 | manda para a tela de login |
| assinatura inválida | 401 | apaga o token e manda para o login |
| token expirado | 401 | tenta renovar; se falhar, login |
| papel errado | 403 | mostra “sem permissão”, não desloga |
Trocar 403 por 401 tem consequência prática: o front desloga a pessoa toda vez que ela clica num botão que não era para ela.
Expiração: quinze minutos, não sete dias
expiresIn: '7d' é confortável e é um erro. Enquanto o token vale, ele vale —
não existe botão de cancelar. Sete dias significa sete dias de acesso para quem
copiou o token do computador emprestado.
Para ver o vencimento acontecer, assine com cinco segundos e espere seis:
import { setTimeout as esperar } from 'node:timers/promises';
const token = jwt.sign({ sub: 7, papel: 'veterinario' }, process.env.JWT_SECRET, {
expiresIn: '5s',
});
console.log('agora :', jwt.verify(token, process.env.JWT_SECRET).papel);
await esperar(6000);
jwt.verify(token, process.env.JWT_SECRET);/private/tmp/patafirme/node_modules/jsonwebtoken/verify.js:190 return done(new TokenExpiredError(‘jwt expired’, new Date(payload.exp * 1000))); ^ TokenExpiredError: jwt expired at /private/tmp/patafirme/node_modules/jsonwebtoken/verify.js:190:21 at getSecret (/private/tmp/patafirme/node_modules/jsonwebtoken/verify.js:97:14) at module.exports [as verify] (/private/tmp/patafirme/node_modules/jsonwebtoken/verify.js:101:10) at file:///private/tmp/patafirme/03-expira.mjs:12:5 { expiredAt: 2026-08-22T23:01:12.000Z }
Node.js v24.16.0
Um token já vencido dentro da API cai naquele
if (erro.name === 'TokenExpiredError') do middleware e vira uma resposta útil:
curl -s -i http://localhost:3100/agenda -H "Authorization: Bearer $EXPIRADO" | head -1
curl -s http://localhost:3100/agenda -H "Authorization: Bearer $EXPIRADO"O campo expirouEm existe para o front distinguir “renove” de “faça login de
novo” sem ficar decodificando token no navegador.
Refresh token: o segundo token, e a rotação
Quinze minutos de token de acesso derrubariam a pessoa quatro vezes por hora. A saída é um segundo token, de vida longa, que serve só para pedir um novo token de acesso.
export function emitirPar(usuario) {
const acesso = jwt.sign(
{ sub: usuario.id, nome: usuario.nome, papel: usuario.papel },
process.env.JWT_SECRET,
{ expiresIn: '15m' },
);
const jti = crypto.randomUUID();
emitidos.add(jti); // em produção: uma linha na tabela
const renovacao = jwt.sign({ sub: usuario.id, jti }, process.env.JWT_REFRESH_SECRET, {
expiresIn: '7d',
});
return { acesso, renovacao };
}
export function consumirRenovacao(token) {
const dados = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
if (!emitidos.delete(dados.jti)) {
throw new Error('refresh token já usado ou revogado');
}
return dados.sub;
}O jti é o que muda tudo: ele é um identificador único guardado no servidor.
Assinatura válida deixou de ser suficiente — o jti precisa estar na lista. É
assim que você ganha de volta o poder de deslogar alguém.
Um script curto faz o ciclo inteiro: loga, abre o payload do token de renovação, renova uma vez e tenta renovar de novo com o mesmo token.
const [, login] = await post('/login', {
email: 'helena@patafirme.vet',
senha: 'raio-x-2026',
});
console.log('payload do refresh :', JSON.stringify(abrir(login.renovacao)));
const [, renovado] = await post('/refresh', { renovacao: login.renovacao });
console.log('jti no login :', abrir(login.renovacao).jti);
console.log('jti apos o refresh :', abrir(renovado.renovacao).jti);
const [status, reuso] = await post('/refresh', { renovacao: login.renovacao });
console.log('reusando o antigo :', status, JSON.stringify(reuso));Três coisas de uma vez. O payload de renovação é magro de propósito: sub,
jti e as datas, nada mais. Cada renovação queima o jti antigo e devolve um
novo — é a rotação. E o token antigo, apresentado uma segunda vez, é
recusado, porque o jti dele já saiu da lista.
Reuso de refresh token é sinal de roubo. Em produção, além de recusar, apague
todos os jti daquele usuário: se o token está sendo usado em dois lugares, um
dos dois não é a pessoa.
Onde o front guarda o token
Aqui a decisão é entre dois riscos, e não existe opção sem risco. Guardar em
localStorage é simples e funciona em qualquer
cliente — mas qualquer script que rode na página lê o valor. Cookie httpOnly
some do JavaScript, e é isso que este teste com jsdom mostra:
const jar = new CookieJar();
jar.setCookieSync('tema=escuro; Path=/', url);
jar.setCookieSync('sessao=eyJhbGciOi...; Path=/; HttpOnly', url);
const { window } = new JSDOM('<p>painel</p>', { url, cookieJar: jar });
window.localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
console.log('localStorage :', window.localStorage.getItem('token'));
console.log('document.cookie:', window.document.cookie);O cookie sessao está lá — o navegador manda ele em toda requisição — mas
document.cookie não o enxerga. Um XSS levaria o token do localStorage e não
levaria o do cookie. Em troca, cookie exige cuidar de CSRF com SameSite e
combina mal com app mobile.
Regra da casa: front e API no mesmo domínio, cookie httpOnly com
SameSite=Lax. API consumida por app ou por outro domínio, localStorage com
token curto e refresh rotacionado.
O segredo no .env e o dia em que ele vazar
O segredo é a única coisa que separa um token legítimo de um forjado. Gere um de verdade e guarde nas variáveis de ambiente:
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"Esquecer de carregar o .env produz um erro que assusta mais do que devia:
node emitir-token.mjs # faltou o --env-file=.envError: secretOrPrivateKey must have a value at module.exports [as sign] (/private/tmp/patafirme/node_modules/jsonwebtoken/sign.js:111:20) at file:///private/tmp/patafirme/emitir-token.mjs:3:19 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v24.16.0
Traduzindo: process.env.JWT_SECRET chegou undefined. Não é problema do JWT,
é o arquivo que não foi lido.
E se o segredo vazar? Você troca — e todo mundo cai junto, porque os tokens que já estavam nos navegadores foram assinados com o antigo:
const antigo = 'VrgnQMwkfmMsBI6anO41vW8j9hxbCVNUdYjWb2uYRb0'; // o que vazou
const novo = 'JYqRlgvj3Yc51UPnFtP41bRUgtTOfChKfmnEZ0X4ZsQ'; // o que entrou no lugar
const emCampo = jwt.sign({ sub: 7, papel: 'veterinario' }, antigo, { expiresIn: '15m' });
try {
jwt.verify(emCampo, novo);
} catch (erro) {
console.log(erro.name + ':', erro.message);
}Não tem meio-termo: girar o segredo desloga a clínica inteira. Por isso os refresh tokens usam um segredo separado, e por isso o payload deve ser mínimo. Medindo o mesmo usuário com dois payloads, no mesmo Node 24.16.0:
for (const [nome, payload] of [['enxuto', enxuto], ['inchado', inchado]]) {
const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
console.log(nome.padEnd(9), Buffer.byteLength(token), 'bytes');
}O payload inchado carregava nome, e-mail, CRMV, dados da unidade e doze
permissões. São 503 bytes a mais em cada requisição — e um cookie tem limite
de 4 KB. Coloque no token o sub e o papel; o resto o servidor busca quando
precisar.
O que vem depois
O fluxo está fechado: login assina, middleware verifica, papel autoriza, refresh renova. O passo seguinte é garantir que ele continue assim depois do próximo commit — é o que você faz em testar rota de API com node:test e supertest, onde a rota protegida vira caso de teste com e sem header. O mapa completo até o deploy está na trilha de Node.
Antes de seguir, faça três requisições para a mesma rota protegida: sem
Authorization, com um token válido e com um token válido de papel insuficiente.
Os resultados esperados são, respectivamente, 401, sucesso e 403. Esse trio
separa autenticação de autorização e vira um teste de regressão útil para toda
API protegida.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Dá para invalidar um JWT antes de ele expirar?
Preciso guardar o token gerado em alguma tabela?
JWT serve para sessão de site com formulário e cookie?
Qual a diferença entre HS256 e RS256?
Posso usar o mesmo segredo do token de acesso no refresh?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0, Express 5.2.1, jsonwebtoken 9.0.3, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- RFC 7519 — JSON Web Token (JWT) — datatracker.ietf.org
- npm — jsonwebtoken — npmjs.com
- MDN — Authorization header — developer.mozilla.org
- IETF — JSON Web Token (RFC 7519) — datatracker.ietf.org



