Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

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.

Rodolfo Mori9 min de leitura

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.

js
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);
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsIm5vbWUiOiJIZWxlbmEgUHJhZG8iLCJwYXBlbCI6InZldGVyaW5hcmlvIiwiaWF0IjoxNzg3NDQwMjIyLCJleHAiOjE3ODc0NDExMjJ9.0zDzGElL1juIYyCaW47eiHoR4Y9151n6HqA9YKifPKg partes: 3

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.

1. cabeçalho 2. payload 3. assinatura alg: HS256 sub, papel, exp HMAC(1.2, segredo) . . base64url: qualquer pessoa lê só o servidor produz

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:

bash
node -p "Buffer.from('$TOKEN'.split('.')[0], 'base64url').toString()"
node -p "Buffer.from('$TOKEN'.split('.')[1], 'base64url').toString()"
{"alg":"HS256","typ":"JWT"} {"sub":7,"nome":"Helena Prado","papel":"veterinario","iat":1787439880,"exp":1787440780}

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:

js
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());
iat 2026-08-22T23:04:40.000Z exp 2026-08-22T23:19:40.000Z

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.

js
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.

bash
curl -s -i -X POST http://localhost:3100/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"helena@patafirme.vet","senha":"raiox2025"}'
HTTP/1.1 401 Unauthorized X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 37 ETag: W/"25-BiVgPowR+6HfS7Pp3NGvbw/ENxc" Date: Sat, 22 Aug 2026 23:04:40 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“E-mail ou senha inválidos”}

Com a senha certa, vem o crachá:

bash
curl -s -X POST http://localhost:3100/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"helena@patafirme.vet","senha":"raio-x-2026"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjcsIm5vbWUiOiJIZWxlbmEgUHJhZG8iLCJwYXBlbCI6InZldGVyaW5hcmlvIiwiaWF0IjoxNzg3NDM5ODgwLCJleHAiOjE3ODc0NDA3ODB9.eWkulBMbj5CJ01ue0GJhLzC0IcOdZR0dVaUvprBKQwo","expiraEm":900}

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.

js
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:

js
app.get('/agenda', autenticar, (req, res) => {
  res.json({ para: req.usuario.nome, consultas: agenda });
});

Sem header nenhum:

bash
curl -s -i http://localhost:3100/agenda
HTTP/1.1 401 Unauthorized {"erro":"Token ausente"}

Com o token da Helena:

bash
curl -s http://localhost:3100/agenda -H "Authorization: Bearer $TOKEN"
{"para":"Helena Prado","consultas":[{"hora":"09:00","animal":"Tofu","tutor":"Ana Vieira"},{"hora":"10:30","animal":"Bolinha","tutor":"Caio Reis"}]}

E o erro que come uma tarde inteira de quem está começando: mandar o token sem a palavra Bearer na frente.

bash
curl -s http://localhost:3100/agenda -H "Authorization: $TOKEN"
{"erro":"Token ausente"}

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:

js
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());
{"sub":12,"nome":"Marcos Lira","papel":"veterinario","iat":1787439892,"exp":1787440792}

O payload agora mente. Mandando esse token para a rota de prontuário:

bash
curl -s -i http://localhost:3100/prontuarios -H "Authorization: Bearer $FALSO"
HTTP/1.1 401 Unauthorized {"erro":"Token inválido"}

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:

js
try {
  jwt.verify(tokenFalso, process.env.JWT_SECRET);
} catch (erro) {
  console.log(erro.name + ':', erro.message);
}
JsonWebTokenError: invalid signature

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:

js
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);
}
JsonWebTokenError: jwt signature is required

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:

js
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: exigirPapelreq.usuario, que só existe depois que autenticar rodou. Com o token do Marcos, a agenda abre:

bash
curl -s -i http://localhost:3100/agenda -H "Authorization: Bearer $MARCOS" | head -1
HTTP/1.1 200 OK

E o prontuário, não:

bash
curl -s -i http://localhost:3100/prontuarios -H "Authorization: Bearer $MARCOS"
HTTP/1.1 403 Forbidden X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 52 ETag: W/"34-5lgn+6CpVdBPwp4xJDeXIIBEJ58" Date: Sat, 22 Aug 2026 23:08:25 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“Seu perfil não tem acesso a este recurso”}

Com o token da Helena, o mesmo prontuário responde:

{"prontuarios":[{"id":1,"animal":"Tofu","tutor":"Ana Vieira","queixa":"Vômito há dois dias"},{"id":2,"animal":"Bolinha","tutor":"Caio Reis","queixa":"Retorno pós-cirúrgico"}]}
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:

js
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);
agora : veterinario

/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:

bash
curl -s -i http://localhost:3100/agenda -H "Authorization: Bearer $EXPIRADO" | head -1
curl -s http://localhost:3100/agenda -H "Authorization: Bearer $EXPIRADO"
HTTP/1.1 401 Unauthorized {"erro":"Token expirado","expirouEm":"2026-08-22T23:04:22.000Z"}

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 para pedir um novo token de acesso.

js
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.

js
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));
payload do refresh : {"sub":7,"jti":"0353a9fb-1a99-40ad-972c-a8dbb5abccf1","iat":1787440123,"exp":1788044923} jti no login : 0353a9fb-1a99-40ad-972c-a8dbb5abccf1 jti apos o refresh : c0c976aa-6898-44bb-9c2e-392d95062fb7 reusando o antigo : 401 {"erro":"refresh token já usado ou revogado"}

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:

js
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);
localStorage : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... document.cookie: tema=escuro

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:

bash
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
VrgnQMwkfmMsBI6anO41vW8j9hxbCVNUdYjWb2uYRb0

Esquecer de carregar o .env produz um erro que assusta mais do que devia:

bash
node emitir-token.mjs   # faltou o --env-file=.env
/private/tmp/patafirme/node_modules/jsonwebtoken/sign.js:111 return failure(new Error('secretOrPrivateKey must have a value')); ^

Error: 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:

js
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);
}
JsonWebTokenError: invalid signature

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:

js
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');
}
enxuto 168 bytes inchado 671 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.

Ver todos os vídeos do canal
  • jwt
  • autenticacao
  • express
  • token
  • seguranca
  • node

Perguntas frequentes

Dá para invalidar um JWT antes de ele expirar?
Não pelo próprio token: ele é verificado por assinatura, sem consulta ao banco. Ou você mantém uma lista de tokens revogados no servidor (e perde a vantagem de não consultar nada), ou usa acesso curto mais refresh revogável, que é o desenho deste artigo.
Preciso guardar o token gerado em alguma tabela?
O token de acesso, não — a assinatura já prova que ele é seu. O refresh token, sim: é a linha no banco que você apaga quando alguém desloga ou quando o dispositivo é roubado.
JWT serve para sessão de site com formulário e cookie?
Serve, mas não é a opção mais simples. Sessão em cookie assinado guardada no servidor resolve o mesmo problema com revogação instantânea. JWT brilha quando o cliente é outro domínio, um app mobile ou outro serviço.
Qual a diferença entre HS256 e RS256?
HS256 usa um segredo só, compartilhado entre quem assina e quem verifica. RS256 usa um par de chaves — a privada assina, a pública verifica. Use RS256 quando serviços diferentes precisam verificar sem poder emitir.
Posso usar o mesmo segredo do token de acesso no refresh?
Pode tecnicamente, mas separe. Com segredos diferentes, um refresh token nunca é aceito como token de acesso por engano, e você consegue girar o segredo de acesso sem derrubar todas as sessões abertas.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. RFC 7519 — JSON Web Token (JWT) — datatracker.ietf.org
  2. npm — jsonwebtoken — npmjs.com
  3. MDN — Authorization header — developer.mozilla.org
  4. IETF — JSON Web Token (RFC 7519) — datatracker.ietf.org

Continue por aqui