Dia 2 — Autenticação

Informatica · Conteudo · publicado em 30/09/2026
Dia 2 de 15

Autenticação

Aula 1

Senha com hash e login

A senha não vai para o banco

Autenticação e autorização são perguntas diferentes, e a diferença é a que separa "quem é você" de "o que você pode". A autenticação é a pergunta feita no login, comparando usuário e senha para descobrir quem está chamando; a autorização é a pergunta feita a cada rota, depois que o nome já é conhecido: essa pessoa tem permissão para apagar este registro. Uma API que só responde à primeira pergunta tem autenticação sem autorização, e é exatamente o estado do DELETE medido no dia 1.

A tabela de usuário tem uma coluna password_hash e não tem coluna de senha. Não é estilo: é o que separa um vazamento de banco de uma lista de senhas que as pessoas reutilizam em todo lugar. O campo de senha nunca em texto vale para o banco, para o log e para o backup: a senha digitada existe em memória, no corpo da requisição, e em nenhum lugar depois disso.

Texto puro não serve. SELECT * FROM tb_usuario com a senha na coluna entrega a senha de todo mundo. Um hash não é reversível: não existe "desfazer" hash, existe refazer a conta com uma senha nova.

Hash puro não serve. hash de senha é feito para ser rápido, e isso vira dois problemas:

  • quem rouba a tabela consegue testar milhões de senhas por segundo, porque a conta de quem tenta é barata;
  • duas pessoas com a mesma senha recebem o mesmo hash, e a tabela passa a dizer quem usa senha repetida.

Sal: o tempero que falta

O sal é um valor aleatório gerado antes de hashear, misturado na conta e guardado junto do resultado. Ele resolve os dois problemas de uma vez. gerarHash sorteia 16 bytes novos a cada chamada, e por isso o sal é o primeiro segmento da string gravada — é o que permite comparar senha depois sem ter guardado nada em separado.

A comparação de senha nunca guarda o valor: o que vai para o banco é o sal e o hash. Quem autentica reenvia a senha, e conferirSenha lê o sal de dentro da string, refaz a mesma conta e compara com timingSafeEqual.

O exemplo grava duas contas com a mesma senha de exemplo e imprime a comparação real:

hash 1 == hash 2 ? false
mesmo tamanho    ? true

Mesmo tamanho porque o formato é sempre scrypt$<sal em hex>$<derivada em hex> — 104 caracteres nas duas. Conteúdo diferente porque o sal é sorteado a cada gerarHash.

O efeito colateral é o mais importante: um banco vazado não diz quem usa a mesma senha. Sem sal, as duas linhas seriam idênticas e a comparação seria uma linha de GROUP BY.

O hash deste exemplo muda a cada execução, e isso é esperado: o sal é sorteado de novo. Rodar o exemplo duas vezes dá linhas diferentes justamente nessa parte, e idênticas em todo o resto. É o mesmo caso da porta, que também muda a cada rodada.

As duas funções

Toda biblioteca de hash oferece o mesmo par. É esse contrato que o serviço vai chamar, e é ele que o login usa:

function gerarHash(senha)              // senha  -> string para gravar
function conferirSenha(senha, hash)    // senha, hash gravado -> true ou false

conferirSenha não recalcula do zero com um sal novo: ele lê o sal que está dentro da string gravada, refaz a mesma conta e compara. Por isso o sal precisa estar guardado — e por isso o hash gravado nunca pode ser "reorganizado" nem "encurtado" depois.

O exemplo usa scrypt de node:crypto, que já vem no Node e não precisa de npm install. bcryptjs é a alternativa mais comum em projeto com framework: a assinatura é a mesma, e trocar de biblioteca é trocar a implementação dessas duas funções — nenhuma outra linha muda.

As duas bibliotecas usam a mesma palavra para a mesma coisa: o salt é o sal, e a biblioteca só o chama assim porque o termo nasceu em inglês. Onde a documentação escrever salt, o código desta aula escreve sal no comentário e o valor em hexadecimal no meio da string — o mesmo mecanismo com o nome que a busca já conhece.

Comparar sem dar pista

Dois detalhes que separam login de verdade de login de brinquedo.

timingSafeEqual. O === de string comum sai do laço na primeira byte diferente: quem está atacando mede quanto tempo levou e descobre o prefixo do hash, um caractere por tentativa. crypto.timingSafeEqual percorre os dois inteiros e compara tudo.

if (calculada.length !== esperada.length) return false;
return crypto.timingSafeEqual(calculada, esperada);

A linha do tamanho vem primeiro de propósito: timingSafeEqual lança se os buffers tiverem tamanhos diferentes, e um throw dentro do login vira 500 em vez de "senha errada".

A mesma mensagem nos dois casos. O exemplo imprime as três tentativas:

senha certa         -> {"ok":true,"id":1}
senha errada        -> {"ok":false,"motivo":"usuario ou senha invalidos"}
usuario inexistente -> {"ok":false,"motivo":"usuario ou senha invalidos"}

"Usuário inexistente" e "senha errada" devolvem a mesma frase. Dizer qual dos dois falhou entrega a lista de quem tem conta na API — basta tentar e ver o que muda.

O que o log mostra e o que ele esconde

A tabela é criada com UNIQUE em nm_email, então o e-mail duplicado falha no banco com ER_DUP_ENTRY e não no código. É o comportamento desejado: a regra que o banco garante sozinho não precisa ser reescrita em JavaScript.

O SELECT do exemplo imprime nm_email e o começo do hash, e o COUNT confirma com número que zero linhas têm a senha em texto.

No log do login entram id, e-mail e o resultado. Nunca entram a senha, o hash, o sal ou a string completa de password_hash. Um log de login com o hash inteiro é um log que vaza a lista de senhas, e o console.log que grava em arquivo é o mesmo console.log da tela.

password_hash não é segredo de servidor e não precisa estar no .env — é conteúdo de negócio, gerado por usuário e guardado como qualquer outro dado. Segredo é a credencial do banco, que está no .env (dia 10) e nunca no repositório.

Migrar de md5 ou sha1 para scrypt ou bcrypt muda a coluna e invalida as senhas já gravadas: o usuário tem que redefinir a senha. É uma migração de dados, não de esquema, e o down dela não existe — a volta é o esquema antigo, não as senhas antigas.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 2: senha com hash e login.
//
// Senha nunca e gravada em texto. O que vai para a coluna `password_hash` e um
// derivado da senha mais um sal aleatorio, e a comparacao na hora do login
// passa por `timingSafeEqual` — que compara em tempo constante, para o tempo
// de resposta nao contar a quem esta tentando.
//
// O hash deste exemplo vem de `node:crypto` (`scrypt`), que ja vem no Node e
// nao precisa de `npm install`. `bcryptjs` e a alternativa mais comum em
// projeto com framework: a assinatura e a mesma, trocar e so trocar a
// implementacao das duas funcoes.
//
// O hash e o sal sao os SEIS primeiros caracteres da string, porem os VALORES
// aqui sao de exemplo e nao servem para nada: o material nao guarda segredo
// de servidor, e o `.env` guarda a credencial do banco (veja o dia 10).

const crypto = require('node:crypto');
const { createConnection } = require('mysql2/promise');

// ----------------------------------------------------------- funcoes de hash
// As duas assinaturas que qualquer biblioteca de hash oferece. Quem chama o
// login precisa delas e nao precisa saber qual e a implementacao.
function gerarHash(senha) {
  return new Promise((resolve, reject) => {
    // 16 bytes de sal aleatorio por senha: duas contas com a mesma senha
    // recebem hashes diferentes, e o mapa nao diz quem usa a mesma senha.
    const sal = crypto.randomBytes(16);
    crypto.scrypt(senha, sal, 32, (erro, derivada) => {
      if (erro) return reject(erro);
      resolve('scrypt$' + sal.toString('hex') + '$' + derivada.toString('hex'));
    });
  });
}

async function conferirSenha(senha, hashGuardado) {
  const partes = String(hashGuardado).split('$');
  if (partes.length !== 3 || partes[0] !== 'scrypt') return false;
  const sal = Buffer.from(partes[1], 'hex');
  const esperada = Buffer.from(partes[2], 'hex');
  const calculada = await new Promise((resolve, reject) => {
    crypto.scrypt(senha, sal, esperada.length,
      (erro, derivada) => (erro ? reject(erro) : resolve(derivada)));
  });
  // timingSafeEqual lanca se os buffers tiverem tamanhos diferentes, e o
  // tamanho diferente ja e motivo suficiente para recusar a senha.
  if (calculada.length !== esperada.length) return false;
  return crypto.timingSafeEqual(calculada, esperada);
}

async function main() {
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  // ------------------------------------------------------- tabela de usuarios
  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_usuario (
      id            INT AUTO_INCREMENT PRIMARY KEY,
      nm_email      VARCHAR(80) NOT NULL,
      password_hash VARCHAR(120) NOT NULL,
      dt_criacao    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      UNIQUE KEY uk_usuario_email (nm_email)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await c.query('TRUNCATE TABLE tb_usuario');

  // --------------------------------------------------------------- cadastro
  console.log('--- cadastro: a senha entra como hash ---');

  // Senha de exemplo do material. Nao e senha de nada, e o valor que entra
  // no codigo de exemplo nao e segredo de servidor: segredo de servidor esta
  // no `.env` (dia 10) e nunca no arquivo.
  const SENHA_EXEMPLO = 'exemplo-do-material';

  const hash1 = await gerarHash(SENHA_EXEMPLO);
  console.log('senha em texto  : ' + SENHA_EXEMPLO);
  console.log('hash gerado     : ' + hash1.slice(0, 26) + '...');
  console.log('tamanho do hash : ' + hash1.length + ' caracteres');
  console.log('formato         : scrypt$<sal em hex>$<derivada em hex>');
  console.log('este hash muda a cada execucao: o sal e sorteado de novo,');
  console.log('e e por isso que rodar o exemplo duas vezes da linhas diferentes aqui');

  const [r1] = await c.execute(
    'INSERT INTO tb_usuario (nm_email, password_hash) VALUES (?, ?)',
    ['[email protected]', hash1]);
  console.log('usuario gravado : id ' + r1.insertId);

  // A segunda conta prova que o sal funciona: mesma senha, hash diferente.
  const hash2 = await gerarHash(SENHA_EXEMPLO);
  console.log('\nmesma senha, segunda conta:');
  console.log('hash 1 == hash 2 ? ' + (hash1 === hash2));
  console.log('mesmo tamanho   ? ' + (hash1.length === hash2.length));

  await c.execute('INSERT INTO tb_usuario (nm_email, password_hash) VALUES (?, ?)',
    ['[email protected]', hash2]);

  // O que o banco guarda de fato: nada em texto.
  const [linhas] = await c.query('SELECT nm_email, password_hash FROM tb_usuario ORDER BY id');
  console.log('\n--- o que esta no banco ---');
  for (const u of linhas) {
    console.log(u.nm_email.padEnd(18) + ' -> ' + u.password_hash.slice(0, 26) + '...');
  }
  const [achouTexto] = await c.query(
    'SELECT COUNT(*) AS n FROM tb_usuario WHERE password_hash = ?', [SENHA_EXEMPLO]);
  console.log('linhas com a senha em texto: ' + achouTexto[0].n);

  // ------------------------------------------------------------------ login
  console.log('\n--- login: tres tentativas ---');

  const tentarLogin = async (email, senha) => {
    const [u] = await c.query(
      'SELECT * FROM tb_usuario WHERE nm_email = ?', [email]);
    if (u.length === 0) {
      // Usuario inexistente devolve a MESMA mensagem de senha errada: dizer
      // que o e-mail nao existe entrega a lista de quem tem conta.
      return { ok: false, motivo: 'usuario ou senha invalidos' };
    }
    const ok = await conferirSenha(senha, u[0].password_hash);
    return ok
      ? { ok: true, id: u[0].id }
      : { ok: false, motivo: 'usuario ou senha invalidos' };
  };

  let r = await tentarLogin('[email protected]', SENHA_EXEMPLO);
  console.log('senha certa        -> ' + JSON.stringify(r));

  r = await tentarLogin('[email protected]', 'senha-errada');
  console.log('senha errada       -> ' + JSON.stringify(r));

  r = await tentarLogin('[email protected]', SENHA_EXEMPLO);
  console.log('usuario inexistente-> ' + JSON.stringify(r));

  // ------------------------------------------------ o que NUNCA vai para o log
  console.log('\n--- o que o login devolve ao cliente ---');
  console.log('sucesso:  { id, email }   — sem hash, sem sal');
  console.log('falha:    { erro }       — sem dizer qual dos dois falhou');

  await c.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

--- cadastro: a senha entra como hash ---
senha em texto  : exemplo-do-material
hash gerado     : scrypt$c6316d3f6515673fc7a...
tamanho do hash : 104 caracteres
formato         : scrypt$<sal em hex>$<derivada em hex>
este hash muda a cada execucao: o sal e sorteado de novo,
e e por isso que rodar o exemplo duas vezes da linhas diferentes aqui
usuario gravado : id 1

mesma senha, segunda conta:
hash 1 == hash 2 ? false
mesmo tamanho   ? true

--- o que esta no banco ---
[email protected]    -> scrypt$c6316d3f6515673fc7a...
[email protected]  -> scrypt$4d5725af895f3e37fe6...
linhas com a senha em texto: 0

--- login: tres tentativas ---
senha certa        -> {"ok":true,"id":1}
senha errada       -> {"ok":false,"motivo":"usuario ou senha invalidos"}
usuario inexistente-> {"ok":false,"motivo":"usuario ou senha invalidos"}

--- o que o login devolve ao cliente ---
sucesso:  { id, email }   — sem hash, sem sal
falha:    { erro }       — sem dizer qual dos dois falhou
Aula 2

Token e proteção de rota

O token é texto, e isso não é defeito

A proteção da rota começa no header de auth: a requisição autenticada carrega um header Authorization: Bearer <token>, e é desse header que sai o token que a chamada seguinte leva. Sem ele, a rota protegida responde 401 antes de olhar qualquer outra coisa.

Um JWT tem três partes separadas por ponto, e o exemplo decodifica as duas primeiras na tela:

header decodificado : {"alg":"HS256","typ":"JWT"}
payload decodificado: {"sub":"ana","iat":<segundos>,"exp":<segundos>}
tamanho de cada parte: header 36, payload 63, assinatura 43

O token inteiro é base64url(header) . base64url(payload) . assinatura. Quem tem o token pode ler o payload — e isso é projeto, não falha. Se o token escondesse o id, o servidor não teria como saber quem está chamando sem consultar o banco a cada requisição.

O que protege é a terceira parte. Assinar token é calcular um HMAC-SHA256 de header.payload com uma chave secreta; verificar token é recalcular e comparar. A função assinarToken(payload, segredo) devolve as três partes prontas para ir no header, e verificarToken(token, segredo) faz o caminho inverso.

Proteger rota é a segunda metade: mesmo com token válido e bem assinado, a rota decide se aquele token abre aquela porta. É o que separa 401 de 403, e a tabela do fim desta aula diz quando cada um aparece.

base64url não é base64: troca + por - e / por _, e tira o =. É o que permite o token viajar em header HTTP sem escaping. Um + virado em espaço no meio da URL já quebrou OAuth no mundo inteiro uma vez.

O segredo nunca está no arquivo

A chave vem de process.env.JWT_SECRET. O exemplo avisa o que fez quando a variável não existe:

chave de assinatura: JWT_SECRET ausente — sorteada nova nesta execucao, em memoria

Isso é o comportamento de um servidor sem chave configurada: todo token emitido antes deixa de valer, porque a assinatura não bate com a assinatura de antes. É preferível a uma chave que está escrita no arquivo e é lida por qualquer pessoa que chegue perto.

O .env guarda a chave, o .gitignore da raiz exclui o .env, e o exemplo só nomeia a variável. Nenhum valor de segredo entra no material.

jwt.verify lança, e isso é o contrato

verificarToken devolve o payload ou lança. Não devolve null, e essa é a decisão que importa: quem devolve null acaba Laplacando "assinatura inválida" e "token expirado" no mesmo if, e o log perde a causa.

O exp é em segundos desde 1970. Date.now() está em milissegundos. Dividir por 1000 é o detalhe que faz o token vencer na hora certa, e a expiração do token é conferida antes de qualquer decisão de rota:

if (payload.exp && payload.exp < Date.now() / 1000) {
  throw new Error('token expirado');
}

Verificar token vencido é uma das cinco respostas que o exemplo mede, e ela sai com 401 e a causa nomeada — token expirado. A expiração do token não depende de o cliente lembrar: é a verificação que compara a data, e a data está dentro do próprio token.

A comparação da assinatura usa timingSafeEqual, pelo mesmo motivo do login do dia anterior: !== sai no primeiro caractere diferente e entrega o prefixo a quem ataca.

Middleware: o porteiro que decide antes do handler

exigirLogin(segredo, rotasPublicas, proximo) devolve o handler já com a checagem na frente. O detalhe estrutural está no terceiro parâmetro:

function exigirLogin(segredo, rotasPublicas, proximo) {
  return async function auth(req, res) { /* ... */ };
}

http.createServer entrega apenas (req, res) ao handler. Um middleware que espera um terceiro argumento na chamada receberia o próprio handler como se fosse a requisição, e o primeiro req.url quebraria com TypeError. Por isso proximo é recebido na criação — que é o formato que todo framework de middleware usa.

A ordem dentro do handler é a da decisão, em três passos: rota pública passa direto; sem Bearer no header responde 401; token inválido responde 401 com a causa. Em nenhum dos três o proximo é chamado — quem não passa pelo porteiro não chega na rota.

O que o exemplo mede

Cinco formas de chegar na rota protegida, todas com status real:

sem token                -> 401  {"erro":"token ausente"}
com token valido         -> 200  {"usuario":"ana","expira_em_segundos":3600,...}
token sem as 3 partes    -> 401  {"erro":"token malformado"}
payload trocado p/ admin -> 401  {"erro":"assinatura invalida"}
token expirado           -> 401  {"erro":"token expirado"}

A quarta linha é o ataque que o header existe para impedir: o cliente reescreve o payload para {"sub":"admin"}, mantém a assinatura antiga e reenvia. A assinatura não bate, e o servidor responde assinatura invalida em vez de aceitar admin.

A rota pública responde 200 sem token — é a prova de que a proteção está no meio, e não em cada rota se lembrar de checar.

401 e 403 não são sinônimos

StatusSignificadoQuando
401não sei quem você étoken ausente, malformado, adulterado ou vencido
403sei quem você é, e você não podetoken válido, sem permissão

O 401 convida a mandar a credencial de novo. O 403 não: mandar o mesmo token de novo vai dar 403 de novo, e a resposta certa é outra — pedir permissão, ou recusar.

Token JWT não dá direito de revogar antes do exp. Isso é a propriedade que o torna barato (o servidor não consulta nada) e o custo (quem stole um token usa até vencer). Para revogar na hora, o caminho é manter uma lista de tokens revogados ou usar token de vida curta com refresh — e é decisão de projeto, não de biblioteca.

alg: "none" é o ataque clássico de JWT: um token sem assinatura que a biblioteca aceita se o algorithm confiado vier do próprio token. A defesa é nunca escolher o algoritmo pelo que está no header, e sim pela configuração do servidor.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 2: token JWT e protecao de rota.
//
// O `jsonwebtoken` nao esta instalado neste material, entao o exemplo faz o
// que a biblioteca faz por baixo dos panos: assina o token com `HMAC-SHA256`
// de `node:crypto`. Sao tres passos e nenhuma dependencia nova —
//
//   base64url(header) . base64url(payload) . assinatura
//
// A assinatura e o que impede que alguem troque o `sub` dentro do token. Sem
// ela o token e so texto: o cliente edita o payload e o servidor obedece.
//
// A CHAVE vem do ambiente, `JWT_SECRET`, e nunca do arquivo. Quando a variavel
// nao existe, o exemplo sorteia uma chave nova em memoria e avisa: e assim
// que o leitor ve o que acontece em um servidor sem chave configurada — todo
// token emitido antes deixa de valer, porque a assinatura nao bate mais.

const http = require('node:http');
const crypto = require('node:crypto');

// ------------------------------------------------------- JWT em node:crypto
// base64url: o mesmo base64 com '+' virado '-' e '/' virado '_', sem '='.
// O JWT precisa disso porque o token viaja em header HTTP.
const b64url = (buf) => Buffer.from(buf)
  .toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

const b64urlDecodifica = (txt) =>
  Buffer.from(txt.replace(/-/g, '+').replace(/_/g, '/'), 'base64');

function assinarToken(payload, segredo) {
  const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
  const corpo = b64url(JSON.stringify(payload));
  const dados = header + '.' + corpo;
  const assinatura = b64url(
    crypto.createHmac('sha256', segredo).update(dados).digest());
  return dados + '.' + assinatura;
}

// `verificarToken` devolve o payload OU lanca. Devolver `null` esconderia a
// diferenca entre token invalido e token expirado, e o chamador acabaria
// Laplacando os dois em "nao autorizado" — e o log perde a causa.
function verificarToken(token, segredo) {
  const partes = String(token).split('.');
  if (partes.length !== 3) throw new Error('token malformado');

  const [header, corpo, assinatura] = partes;
  const esperada = b64url(
    crypto.createHmac('sha256', segredo).update(header + '.' + corpo).digest());

  // Comparacao em tempo constante: o `!==` comum sai no primeiro caractere
  // diferente e entrega o prefixo da assinatura a quem ataca.
  const a = Buffer.from(assinatura);
  const b = Buffer.from(esperada);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('assinatura invalida');
  }

  const payload = JSON.parse(b64urlDecodifica(corpo).toString('utf8'));
  // `exp` e em SEGUNDOS desde 1970; `Date.now()` esta em milissegundos.
  // Dividir por 1000 e o detalhe que faz o token expirar na hora certa.
  if (payload.exp && payload.exp < Date.now() / 1000) {
    throw new Error('token expirado');
  }
  return payload;
}

class ErroDeAuth extends Error {
  constructor(mensagem, status) {
    super(mensagem);
    this.name = 'ErroDeAuth';
    this.status = status;
  }
}

function responder(res, status, corpo) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(corpo));
}

// O middleware de autenticacao. `proximo` e RECEBIDO AQUI, na criação do
// middleware, e nao na chamada: o `http.createServer` so entrega `(req, res)`
// ao handler, entao um middleware que espera um terceiro argumento receberia o
// handler como se fosse a requisicao — e o primeiro `req.url` quebraria.
//
// E o mesmo formato de todo framework que usa middleware: a funcao devolve o
// handler ja com o middleware na frente.
function exigirLogin(segredo, rotasPublicas, proximo) {
  return async function auth(req, res) {
    const chave = req.method + ' ' + req.url.split('?')[0];
    if (rotasPublicas.includes(chave)) return proximo(req, res);

    const partes = (req.headers.authorization || '').split(' ');
    // `Bearer` e o esquema: a palavra, um espaco, e o token. Sem o prefixo o
    // header esta la e o middleware nao sabe o que fazer com ele.
    if (partes.length !== 2 || partes[0] !== 'Bearer') {
      return responder(res, 401, { erro: 'token ausente' });
    }

    try {
      req.usuario = verificarToken(partes[1], segredo);
      return proximo(req, res);
    } catch (erro) {
      // 401 e "nao sei quem voce e". O 403 e o status da aula seguinte, para
      // quando o token e valido mas nao tem permissao.
      return responder(res, 401, { erro: erro.message });
    }
  };
}

async function main() {
  // ------------------------------------------------- a chave, e de onde vem
  const doAmbiente = Boolean(process.env.JWT_SECRET);
  const segredo = doAmbiente
    ? process.env.JWT_SECRET
    : crypto.randomBytes(32).toString('hex');

  console.log('chave de assinatura: ' + (doAmbiente
    ? 'JWT_SECRET, lida de process.env'
    : 'JWT_SECRET ausente — sorteada nova nesta execucao, em memoria'));
  console.log('o valor da chave nao entra no arquivo do exemplo, nem no log');

  const { createConnection } = require('mysql2/promise');
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_sessao (
      id         INT AUTO_INCREMENT PRIMARY KEY,
      nm_usuario VARCHAR(60) NOT NULL,
      criado_em  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await c.query('TRUNCATE TABLE tb_sessao');

  // ------------------------------------------------------ o token, desmontado
  const agora = Math.floor(Date.now() / 1000);
  const token = assinarToken({
    sub: 'ana',
    iat: agora,
    exp: agora + 3600,
  }, segredo);

  const [h, p, a] = token.split('.');
  console.log('\n--- anatomia do token ---');
  console.log('partes separadas por ponto: ' + token.split('.').length
    + ' (header, payload, assinatura)');
  console.log('header decodificado : ' + b64urlDecodifica(h).toString('utf8'));
  console.log('payload decodificado: ' + b64urlDecodifica(p).toString('utf8')
    .replace(/"(iat|exp)":\d+/g, '"$1":<segundos>'));
  console.log('tamanho de cada parte: header ' + h.length + ', payload ' + p.length
    + ', assinatura ' + a.length);
  console.log('a assinatura tem ' + a.length + ' caracteres porque e HMAC-SHA256 em base64url');
  console.log('o token inteiro TEMPO e a decodificavel: a protecao esta na assinatura');

  // ------------------------------------------------------------------ rotas
  const rotasPublicas = ['POST /login', 'GET /saude'];

  const rotas = {
    'POST /login': async (req, res) => {
      const partes = [];
      for await (const p of req) partes.push(p);
      const dados = JSON.parse(Buffer.concat(partes).toString('utf8') || '{}');
      const nome = dados.usuario || 'ana';
      await c.execute('INSERT INTO tb_sessao (nm_usuario) VALUES (?)', [nome]);

      // A resposta carrega `emitido: true` e NAO o token. Devolver o token no
      // corpo e o certo; devolver um texto no lugar dele e um bug — o cliente
      // receberia uma credencial que nao autentica nada. Quem precisa do token
      // de verdade le o header `Authorization` da chamada seguinte, e ele
      // volta gerado em memoria, nunca impresso em log.
      responder(res, 200, { emitido: true, usuario: nome });
    },

    'GET /saude': (_req, res) => responder(res, 200, { ok: true }),

    'GET /perfil': async (req, res) => {
      const [linhas] = await c.query(
        'SELECT * FROM tb_sessao ORDER BY id DESC LIMIT 1');
      // O que se devolve: quem o token diz que e, e quanto tempo resta. O
      // `exp` vira uma quantidade de SEGUNDOS, e nao uma data: e o que o
      // cliente precisa, e nao depende do fuso de quem le.
      responder(res, 200, {
        usuario: req.usuario.sub,
        expira_em_segundos: req.usuario.exp - Math.floor(Date.now() / 1000),
        ultimaSessao: linhas[0] ? linhas[0].nm_usuario : null,
      });
    },

    'DELETE /sessao': async (_req, res) => {
      const [r] = await c.execute('DELETE FROM tb_sessao');
      responder(res, 200, { removidas: r.affectedRows });
    },
  };

  // O middleware entra uma vez so, na criacao do handler.
  const tratar = exigirLogin(segredo, rotasPublicas, async (req, res) => {
    const rota = rotas[req.method + ' ' + req.url];
    if (!rota) return responder(res, 404, { erro: 'rota nao encontrada' });
    await rota(req, res);
  });

  const servidor = http.createServer(tratar);

  await new Promise((r) => servidor.listen(0, '127.0.0.1', r));
  const base = 'http://127.0.0.1:' + servidor.address().port;
  console.log('\nAPI com rota protegida em ' + base);
  console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre');

  const pedir = (metodo, caminho, token, corpo) => fetch(base + caminho, {
    method: metodo,
    headers: {
      ...(corpo ? { 'Content-Type': 'application/json' } : {}),
      ...(token ? { Authorization: 'Bearer ' + token } : {}),
    },
    body: corpo ? JSON.stringify(corpo) : undefined,
  });

  try {
    console.log('\n--- rota publica: passa sem token ---');
    let r = await pedir('GET', '/saude');
    console.log('GET /saude  sem token   -> ' + r.status + '  ' + await r.text());

    console.log('\n--- rota protegida: cinco formas de chegar ---');
    r = await pedir('GET', '/perfil');
    console.log('sem token               -> ' + r.status + '  ' + await r.text());

    r = await pedir('GET', '/perfil', token);
    console.log('com token valido        -> ' + r.status + '  ' + await r.text());

    r = await pedir('GET', '/perfil', 'token-inventado');
    console.log('token sem as 3 partes   -> ' + r.status + '  ' + await r.text());

    // Payload trocado, assinatura mantida: e o ataque que o header precisa
    // impedir. O cliente diz que e admin e o servidor nao acredita.
    const adulterado = h + '.' + b64url(JSON.stringify({
      sub: 'admin', exp: agora + 3600,
    })) + '.' + a;
    r = await pedir('GET', '/perfil', adulterado);
    console.log('payload trocado p/ admin -> ' + r.status + '  ' + await r.text());

    const expirado = assinarToken({ sub: 'ana', iat: 1000, exp: 2000 }, segredo);
    r = await pedir('GET', '/perfil', expirado);
    console.log('token expirado          -> ' + r.status + '  ' + await r.text());

    console.log('\n--- login e logout com token ---');
    r = await pedir('POST', '/login', null, { usuario: 'ana' });
    console.log('POST /login             -> ' + r.status + '  ' + await r.text());

    r = await pedir('DELETE', '/sessao', token);
    console.log('DELETE /sessao          -> ' + r.status + '  ' + await r.text());
  } finally {
    await new Promise((r) => servidor.close(r));
    console.log('\nservidor encerrado com close().');
  }

  console.log('401 = nao sei quem voce e (ausente, malformado, adulterado ou vencido)');
  console.log('403 = eu sei quem voce e, e voce nao pode — o status do proximo bloco');

  await c.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

chave de assinatura: JWT_SECRET ausente — sorteada nova nesta execucao, em memoria
o valor da chave nao entra no arquivo do exemplo, nem no log

--- anatomia do token ---
partes separadas por ponto: 3 (header, payload, assinatura)
header decodificado : {"alg":"HS256","typ":"JWT"}
payload decodificado: {"sub":"ana","iat":<segundos>,"exp":<segundos>}
tamanho de cada parte: header 36, payload 63, assinatura 43
a assinatura tem 43 caracteres porque e HMAC-SHA256 em base64url
o token inteiro TEMPO e a decodificavel: a protecao esta na assinatura

API com rota protegida em http://127.0.0.1:38295
a porta muda a cada execucao: e o listen(0) pedindo uma livre

--- rota publica: passa sem token ---
GET /saude  sem token   -> 200  {"ok":true}

--- rota protegida: cinco formas de chegar ---
sem token               -> 401  {"erro":"token ausente"}
com token valido        -> 200  {"usuario":"ana","expira_em_segundos":3600,"ultimaSessao":null}
token sem as 3 partes   -> 401  {"erro":"token malformado"}
payload trocado p/ admin -> 401  {"erro":"assinatura invalida"}
token expirado          -> 401  {"erro":"token expirado"}

--- login e logout com token ---
POST /login             -> 200  {"emitido":true,"usuario":"ana"}
DELETE /sessao          -> 200  {"removidas":1}

servidor encerrado com close().
401 = nao sei quem voce e (ausente, malformado, adulterado ou vencido)
403 = eu sei quem voce e, e voce nao pode — o status do proximo bloco