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

Hash de senha com bcrypt no Node: salvar e comparar

Por que senha nunca vai em texto puro nem em MD5, como gerar o hash com bcrypt, escolher o custo e comparar no login sem entregar pista nenhuma.

Rodolfo Mori14 min de leitura

Senha não se guarda. Guarda-se o hash dela. No Node isso são duas funções — bcrypt.hash na hora do cadastro e bcrypt.compare na hora do login — e uma decisão no meio que quase ninguém explica direito: o custo.

Os exemplos todos são da PetVida, uma clínica veterinária cujo portal deixa o tutor entrar para ver a carteirinha de vacina do bichinho. Tem cadastro, tem login, e tem uma tabela de tutores que um dia pode vazar.

O termo correto é função de derivação de senha: o bcrypt mistura senha, salt e custo para produzir um hash lento de verificar. Em palavras simples, o sistema guarda uma impressão comparável, não uma versão escondida da senha.

A ficha guarda a impressão digital, não a mão

Uma clínica consegue comparar a impressão digital apresentada com a ficha sem reconstruir a mão da pessoa a partir do papel. O bcrypt segue essa direção: hash cria o registro e compare processa a tentativa para conferir se ela combina. O salt faz senhas iguais gerarem registros diferentes; o custo aumenta o trabalho de cada tentativa.

Faça duas verificações antes de avançar: gere dois hashes para a mesma senha e preveja se serão iguais; depois compare a senha correta com ambos. A saída deve mostrar hashes diferentes e dois resultados verdadeiros. É a prova pequena do papel do salt e de como o login funciona sem descriptografar nada.

Criptografia tem volta: você cifra com uma chave e decifra com ela de novo. Hash não tem. É uma conta que transforma qualquer texto num resultado de tamanho fixo, e não existe conta inversa que devolva o texto original.

Isso é exatamente o que você quer para senha. Repare no que acontece sem hash nenhum, com a tabela de tutores em texto puro:

js
import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE tutores (id INTEGER PRIMARY KEY, email TEXT, senha TEXT)`);
db.exec(`INSERT INTO tutores (email, senha) VALUES
  ('ana@petvida.com.br', 'consultaVet2026'),
  ('bruno@petvida.com.br', 'bruno123'),
  ('carla@petvida.com.br', 'consultaVet2026')`);

console.table(db.prepare('SELECT * FROM tutores').all());
┌─────────┬────┬────────────────────────┬───────────────────┐ │ (index) │ id │ email │ senha │ ├─────────┼────┼────────────────────────┼───────────────────┤ │ 0 │ 1 │ 'ana@petvida.com.br' │ 'consultaVet2026' │ │ 1 │ 2 │ 'bruno@petvida.com.br' │ 'bruno123' │ │ 2 │ 3 │ 'carla@petvida.com.br' │ 'consultaVet2026' │ └─────────┴────┴────────────────────────┴───────────────────┘

Um SELECT e o atacante tem tudo. E não só o portal da clínica: como muita gente repete senha, ele acabou de ganhar o e-mail e o banco dessas pessoas também. Quem tem acesso ao banco por qualquer caminho — um backup mal guardado, uma rota que não soube validar a entrada, uma credencial exposta — lê a coluna inteira.

Com hash, o que está guardado não serve para logar em lugar nenhum. Nem no seu próprio sistema, porque o servidor não compara o que recebeu com o que está salvo: ele refaz a conta e compara o resultado.

Por que MD5 e SHA-256 não servem para senha

A primeira ideia de quem descobre hash é usar o que já vem no Node:

js
import { createHash } from 'node:crypto';

const sha = (s) => createHash('sha256').update(s).digest('hex');

console.log('sha256:', sha('consultaVet2026'));
console.log('sha256:', sha('consultaVet2026'));

const inicio = performance.now();
const N = 1_000_000;
for (let i = 0; i < N; i++) sha('senha' + i);
const ms = performance.now() - inicio;

console.log(`${N} hashes SHA-256 em ${ms.toFixed(0)} ms`);
console.log(`${Math.round(N / (ms / 1000)).toLocaleString('pt-BR')} hashes por segundo`);
sha256: 54877e5925284e133ae87766dce107868cc06422ac08f180bd6dd1aa085f4df0 sha256: 54877e5925284e133ae87766dce107868cc06422ac08f180bd6dd1aa085f4df0 1000000 hashes SHA-256 em 337 ms 2.964.696 hashes por segundo

Dois problemas aparecem nessa saída, e os dois são fatais.

O primeiro: a mesma senha dá sempre o mesmo hash. Quem rouba o banco não precisa quebrar nada — basta procurar aquele 54877e59… numa lista pronta de senhas já calculadas. É a chamada rainbow table, e ela existe há décadas para MD5 e SHA.

O segundo é a velocidade. Quase 3 milhões de hashes por segundo numa máquina comum, num processo só, sem placa de vídeo. SHA-256 foi projetado para ser rápido — é o que se quer de um hash de arquivo. Para senha, velocidade é a característica errada: ela é a velocidade do atacante testando chutes.

Salt: a mesma senha, dois hashes diferentes

O bcrypt resolve o primeiro problema sorteando um valor aleatório para cada hash — o salt — e misturando ele na conta. Compare as duas listas, com as mesmas três pessoas da PetVida:

js
import bcrypt from 'bcrypt';
import { createHash } from 'node:crypto';

const senhas = [
  ['ana@petvida.com.br', 'consultaVet2026'],
  ['bruno@petvida.com.br', 'bruno123'],
  ['carla@petvida.com.br', 'consultaVet2026'],
];

for (const [email, senha] of senhas) {
  console.log(email.padEnd(22), createHash('sha256').update(senha).digest('hex').slice(0, 24));
}
console.log('');
for (const [email, senha] of senhas) {
  console.log(email.padEnd(22), await bcrypt.hash(senha, 12));
}
ana@petvida.com.br 54877e5925284e133ae87766 bruno@petvida.com.br 956cd9e97c44cbb5c9f18ccf carla@petvida.com.br 54877e5925284e133ae87766

ana@petvida.com.br $2b$12$mifJNWkKHLYjkCmYNOis/Ohy0rviMck5IGq98cetvH0Oh7FfsfwbS bruno@petvida.com.br $2b$12$xQ8ntYW0aF3klKNH74H4ZeS57nN6KQCDpR.Lmwbs3vi6Hgq1KaSeC carla@petvida.com.br $2b$12$knshs8ppXji9rWM5ONhXJuX.jl6qOxqrqfwqNNgCQT3MvngEq1I5e

No SHA-256, Ana e Carla têm o hash idêntico — o vazamento entrega de graça que as duas usam a mesma senha. No bcrypt, os três hashes são diferentes, e o de Ana seria diferente de novo se você rodasse o script mais uma vez.

A consequência prática é grande: como cada linha tem o próprio salt, uma rainbow table pré-calculada não serve para nada, e quebrar mil contas custa mil vezes o trabalho de quebrar uma.

bcrypt.hash e o custo que você escolhe

Instale o pacote:

bash
npm i bcrypt
added 3 packages, and audited 4 packages in 545ms

found 0 vulnerabilities

E o cadastro vira uma linha. O segundo argumento é o custo — as rounds:

js
import bcrypt from 'bcrypt';

const senha = 'consultaVet2026';

const hashA = await bcrypt.hash(senha, 12);
const hashB = await bcrypt.hash(senha, 12);

console.log(hashA);
console.log(hashB);
console.log('são iguais?', hashA === hashB);
console.log('compare com hashA:', await bcrypt.compare(senha, hashA));
console.log('compare com hashB:', await bcrypt.compare(senha, hashB));
$2b$12$xDHk/hGPeDGhnbhJdGMmJen5gPAQvYgjyCudl/QpLwFjdnAEeDnMu $2b$12$yQrwrxSDvMYniFKxFKYKQuTFqmWd8xjslhGjjEc6xvO1200.e.u/e são iguais? false compare com hashA: true compare com hashB: true

Guarde essa saída, porque ela é a coisa mais estranha do bcrypt para quem está começando: a mesma senha gerou duas strings diferentes, e o compare diz true para as duas. Não tem contradição. O salt de cada hash está guardado dentro do próprio hash, e o compare sabe pescar ele de lá antes de refazer a conta. Já já a gente abre a string e vê onde ele mora.

Quanto cada round custa na sua máquina

O custo é um expoente, não um multiplicador: cada round a mais dobra o trabalho. 12 significa 2¹² = 4096 iterações internas. Isso é abstrato demais para decidir alguma coisa, então medi na máquina daqui:

js
import bcrypt from 'bcrypt';

const senha = 'consultaVet2026';
await bcrypt.hash(senha, 8); // aquecimento, descartado

for (const custo of [8, 10, 12, 14]) {
  const tempos = [];
  for (let i = 0; i < 5; i++) {
    const inicio = performance.now();
    await bcrypt.hash(senha, custo);
    tempos.push(performance.now() - inicio);
  }
  const media = tempos.reduce((a, b) => a + b, 0) / tempos.length;
  console.log(`custo ${custo}: ${media.toFixed(0)} ms por hash  (${(1000 / media).toFixed(1)} hashes/s)`);
}
custo 8: 14 ms por hash (72.7 hashes/s) custo 10: 55 ms por hash (18.3 hashes/s) custo 12: 217 ms por hash (4.6 hashes/s) custo 14: 874 ms por hash (1.1 hashes/s)

MacBook com Apple M4 Pro, Node 24.16.0, bcrypt 6.0.0, média de 5 execuções depois de uma de aquecimento. Repeti a bateria três vezes e os números variaram menos de 5%.

Olhe a coluna do tempo: 14 → 55 → 217 → 874. É o dobro a cada round, exatamente como a conta promete. E olhe a última coluna, que é a que interessa para segurança: no custo 14 esta máquina faz 1,1 hash por segundo, contra os 2,9 milhões do SHA-256. É a mesma máquina. A diferença toda é o algoritmo ter sido feito para custar caro.

custo tempo por hash tentativas por segundo onde usar
8 14 ms ~73 só em teste automatizado, para a suíte não demorar
10 55 ms ~18 mínimo aceitável em produção hoje
12 217 ms ~4,6 ponto de partida deste laboratório; meça no seu servidor
14 874 ms ~1,1 dado muito sensível, e só se a sua máquina aguentar

A régua não é “o maior número que couber”. É esta: escolha o maior custo cujo tempo o seu login ainda tolera, contando que ele roda uma vez por autenticação. Entre 200 e 300 ms é uma faixa confortável — o tutor da PetVida não percebe, e o atacante perde seis ordens de grandeza.

E repita a medição no servidor de verdade. Custo 12 no seu MacBook pode virar custo 12 a 900 ms num container pequeno de nuvem, e aí o login fica lento para todo mundo.

hashSync trava a fila inteira da API

Esses 217 ms são CPU ocupada. E aqui mora a pegadinha que separa código de tutorial de código de produção: existe bcrypt.hashSync, ele parece mais simples, e ele para o event loop do Node — ou seja, para todas as outras requisições ao mesmo tempo.

Dá para medir. O truque é bater um ponto a cada 10 ms e olhar o maior buraco entre duas batidas:

js
import bcrypt from 'bcrypt';

function baterPonto() {
  let ultimo = performance.now();
  let maiorBuraco = 0;
  const t = setInterval(() => {
    const agora = performance.now();
    maiorBuraco = Math.max(maiorBuraco, agora - ultimo);
    ultimo = agora;
  }, 10);
  return () => (clearInterval(t), maiorBuraco);
}
const respirar = () => new Promise((r) => setTimeout(r, 60));

let parar = baterPonto();
await respirar();
for (let i = 0; i < 3; i++) bcrypt.hashSync('consultaVet2026', 12);
await respirar();
console.log('3x hashSync :', parar().toFixed(0), 'ms de event loop travado');

parar = baterPonto();
await respirar();
await Promise.all([0, 1, 2].map(() => bcrypt.hash('consultaVet2026', 12)));
await respirar();
console.log('3x hash     :', parar().toFixed(0), 'ms de event loop travado');
3x hashSync : 662 ms de event loop travado 3x hash : 12 ms de event loop travado

Três cadastros simultâneos com a versão síncrona deixaram a API surda por 662 ms. Com a versão assíncrona, 12 ms — e os três hashes ainda assim terminaram juntos (215 ms para os três, contra 194 ms para um sozinho), porque o bcrypt nativo joga o trabalho para a pool de threads do Node e o event loop continua atendendo o resto.

Anatomia do hash: o $2b$12$ carrega o próprio salt

Aqueles 60 caracteres não são uma bolha aleatória. São quatro campos separados por $, e você pode abrir eles na mão:

js
import bcrypt from 'bcrypt';

const hash = await bcrypt.hash('consultaVet2026', 12);
const [, versao, custo, resto] = hash.split('$');

console.log('hash completo :', hash);
console.log('tamanho       :', hash.length, 'caracteres');
console.log('versão        :', versao);
console.log('custo         :', custo);
console.log('salt          :', resto.slice(0, 22));
console.log('digest        :', resto.slice(22));
console.log('getRounds     :', bcrypt.getRounds(hash));
hash completo : $2b$12$NZB9e59ma3/F7.2yZjmbU.ZjuWBNZhC52I3c9EC4.ut7N6HDhs4MW tamanho : 60 caracteres versão : 2b custo : 12 salt : NZB9e59ma3/F7.2yZjmbU. digest : ZjuWBNZhC52I3c9EC4.ut7N6HDhs4MW getRounds : 12
campo valor no exemplo o que é
versão 2b a variante do bcrypt; 2b é a atual e corrigiu um bug de 2014
custo 12 o expoente das rounds, gravado junto
salt 22 caracteres o aleatório sorteado para este hash
digest 31 caracteres o resultado da conta em si

É por isso que você não cria uma coluna salt no banco. Uma coluna de 60 caracteres guarda tudo: o algoritmo sabe se reconstituir a partir da própria string. E é por isso que o compare funciona sem você passar nada além da senha e do hash.

bcrypt.compare no login, e nunca ===

O login não desfaz o hash. Ele lê o salt e o custo de dentro da string guardada, refaz a conta com a senha que chegou e vê se o digest bate:

js
import bcrypt from 'bcrypt';

const guardado = await bcrypt.hash('consultaVet2026', 12);

console.log('senha certa   :', await bcrypt.compare('consultaVet2026', guardado));
console.log('senha errada  :', await bcrypt.compare('consultaVet2025', guardado));
console.log('caixa trocada :', await bcrypt.compare('Consultavet2026', guardado));

const hashDaTentativa = await bcrypt.hash('consultaVet2026', 12);
console.log('=== com hash novo :', hashDaTentativa === guardado);
console.log('=== com a senha   :', 'consultaVet2026' === guardado);
senha certa : true senha errada : false caixa trocada : false === com hash novo : false === com a senha : false

As duas últimas linhas são o erro de raciocínio mais comum de quem chega aqui. Hashear a senha digitada e comparar com === parece a mesma coisa e nunca funciona: o hash novo tem um salt novo, então dá diferente mesmo quando a senha está certa. Só o compare sabe reaproveitar o salt antigo.

O login inteiro: cadastro, sessão e curl

Juntando tudo numa API Express — a mesma estrutura de rota que você já viu no Express, agora com as duas funções no lugar certo:

js
import express from 'express';
import bcrypt from 'bcrypt';

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

const CUSTO = 12;
const tutores = []; // no lugar do banco, para o exemplo caber aqui

app.post('/tutores', async (req, res) => {
  const { email, senha } = req.body;
  if (!senha || senha.length < 8) {
    return res.status(400).json({ erro: 'senha precisa de 8 caracteres ou mais' });
  }
  const senhaHash = await bcrypt.hash(senha, CUSTO);
  tutores.push({ id: tutores.length + 1, email, senhaHash });
  res.status(201).json({ id: tutores.length, email });
});

app.post('/sessoes', async (req, res) => {
  const { email, senha } = req.body;
  const tutor = tutores.find((t) => t.email === email);

  const confere = tutor ? await bcrypt.compare(senha, tutor.senhaHash) : false;
  if (!confere) {
    return res.status(401).json({ erro: 'e-mail ou senha inválidos' });
  }
  res.json({ id: tutor.id, email: tutor.email });
});

app.listen(3401, () => console.log('PetVida ouvindo em http://localhost:3401'));

Repare em três decisões. A senha em texto puro só existe dentro da variável senha, e some no fim da requisição — ela nunca é gravada nem logada. A resposta do cadastro devolve id e email, nunca o campo senhaHash. E a mensagem de erro do login é uma só, e-mail ou senha inválidos, mesmo quando o problema é só o e-mail: dizer “esse e-mail não existe” entrega ao atacante a lista de quem é cliente da clínica.

Subindo e batendo com curl:

bash
# sed -n '1p;$p' deixa só a linha de status e o corpo da resposta
curl -s -i -X POST localhost:3401/tutores -H 'Content-Type: application/json' \
  -d '{"email":"ana@petvida.com.br","senha":"consultaVet2026"}' | sed -n '1p;$p'

curl -s -i -X POST localhost:3401/sessoes -H 'Content-Type: application/json' \
  -d '{"email":"ana@petvida.com.br","senha":"consultaVet2026"}' | sed -n '1p;$p'

curl -s -i -X POST localhost:3401/sessoes -H 'Content-Type: application/json' \
  -d '{"email":"ana@petvida.com.br","senha":"consultaVet2025"}' | sed -n '1p;$p'
HTTP/1.1 201 Created {"id":1,"email":"ana@petvida.com.br"} HTTP/1.1 200 OK {"id":1,"email":"ana@petvida.com.br"} HTTP/1.1 401 Unauthorized {"erro":"e-mail ou senha inválidos"}

O 401 é o status certo aqui: a credencial não confere. Se você tem dúvida de qual código devolver em cada caso, a lição de métodos HTTP e status code tem a tabela inteira. E aquela validação do senha.length < 8 escrita na mão fica bem melhor com validação de entrada com Zod.

O erro que derruba a rota de login

Aquele tutor ? ... : false do exemplo não é firula. Tire ele e a rota morre no primeiro e-mail que não existe no banco:

js
import bcrypt from 'bcrypt';

const tutores = [
  { email: 'ana@petvida.com.br', senhaHash: await bcrypt.hash('consultaVet2026', 12) },
];

const email = 'bruno@petvida.com.br'; // tutor que não existe
const tutor = tutores.find((t) => t.email === email);

const confere = await bcrypt.compare('consultaVet2026', tutor?.senhaHash);
console.log(confere);
/private/tmp/petvida/node_modules/bcrypt/bcrypt.js:204 error = new Error('data and hash arguments required'); ^

Error: data and hash arguments required at Object.compare (/private/tmp/petvida/node_modules/bcrypt/bcrypt.js:204:17) at /private/tmp/petvida/node_modules/bcrypt/promises.js:26:12 at new Promise (<anonymous>) at Object.promise (/private/tmp/petvida/node_modules/bcrypt/promises.js:17:12) at Object.compare (/private/tmp/petvida/node_modules/bcrypt/bcrypt.js:200:25) at file:///private/tmp/petvida/login.mjs:10:30

Node.js v24.16.0

Leia a mensagem literalmente: data and hash arguments required. O bcrypt recebeu undefined no lugar do hash e se recusou a trabalhar — com razão, já que devolver false calado esconderia um bug. O undefined veio do find que não achou ninguém.

Em produção, o rastro é pior: o find vira uma consulta ao banco, a promise rejeita, e o tutor recebe um 500 genérico em vez do 401. Você vai debugar “erro no login” achando que é o bcrypt, quando o problema é um e-mail digitado errado.

A correção é a da rota lá em cima: só chame o compare quando existir hash para comparar — ou, melhor ainda, use o hash fantasma da última seção.

Subir o custo sem quebrar quem já tem conta

Como o custo está gravado dentro do hash, hash antigo continua funcionando depois que você aumenta o padrão do projeto. Dá para migrar aos poucos, no login de cada pessoa:

js
import bcrypt from 'bcrypt';

const CUSTO_ATUAL = 12;

const antigo = await bcrypt.hash('consultaVet2026', 8); // gravado quando o padrão era 8
console.log('hash antigo :', antigo);
console.log('ainda valida:', await bcrypt.compare('consultaVet2026', antigo));
console.log('custo dele  :', bcrypt.getRounds(antigo));

if (bcrypt.getRounds(antigo) < CUSTO_ATUAL) {
  const novo = await bcrypt.hash('consultaVet2026', CUSTO_ATUAL);
  console.log('regravado   :', novo, '→ custo', bcrypt.getRounds(novo));
}
hash antigo : $2b$08$h11kcSPLLyYxIC0x2vHZOepx00B9GJuRpHtaa4VB0PDKE3v6mnZei ainda valida: true custo dele : 8 regravado : $2b$12$T9vZkKbxQ0fjFFY4YZyE9.w.t/pcac5vYUPi6uDhprNrp5exwj6vy → custo 12

Coloque esse bloco depois do compare bem-sucedido na rota de sessão, onde você tem a senha em texto puro na mão pela última vez. Quem loga, migra sozinho. Quem nunca mais voltar continua com o hash antigo, que é ruim, mas é melhor do que forçar redefinição de senha para a base inteira.

Deixe o custo numa variável de ambiente: assim o teste roda com 8 e a produção com 12 sem trocar uma linha de código.

bcrypt, argon2 e o scrypt que já vem no node:crypto

bcrypt não é a única opção, e desde 2015 nem é a mais recomendada. Medi as três na mesma máquina:

js
import bcrypt from 'bcrypt';
import argon2 from 'argon2';
import { scrypt as scryptCb } from 'node:crypto';
import { promisify } from 'node:util';

const scrypt = promisify(scryptCb);
const senha = 'consultaVet2026';

async function medir(rotulo, fn) {
  await fn(); // aquecimento
  const t = [];
  for (let i = 0; i < 5; i++) {
    const inicio = performance.now();
    await fn();
    t.push(performance.now() - inicio);
  }
  console.log(`${rotulo.padEnd(28)} ${(t.reduce((a, b) => a + b, 0) / t.length).toFixed(0).padStart(4)} ms`);
}

await medir('bcrypt custo 12', () => bcrypt.hash(senha, 12));
await medir('argon2id (padrão da lib)', () => argon2.hash(senha));
await medir('scrypt N=16384 (node:crypto)', () => scrypt(senha, 'sal-de-16-bytes', 64));

console.log('\nargon2 :', await argon2.hash(senha));
bcrypt custo 12 194 ms argon2id (padrão da lib) 24 ms scrypt N=16384 (node:crypto) 20 ms

argon2 : $argon2id$v=19$m=65536,p=4,t=3$yEyp2ojfoSCQ1bBq5eYeLA$EYw7moGdS62spvOWh9Hd28cNSJ/fTcTUrTAXn8ei5V4

Cuidado com a leitura preguiçosa desses números. O argon2 ficou oito vezes mais rápido que o bcrypt, mas não é “dez vezes mais fraco”: olhe o m=65536 no hash dele — são 64 MB de memória por hash. Ele foi feito para ser caro em memória, não em tempo, justamente porque placa de vídeo tem muito núcleo e pouca memória por núcleo. É a defesa que o bcrypt, de 1999, não tem.

algoritmo precisa instalar custa caro em quando eu escolho
bcrypt sim, pacote nativo tempo de CPU projeto que já usa, ou time que precisa de algo simples e testado
argon2id sim, pacote nativo memória e tempo projeto novo, se o servidor tiver RAM sobrando
scrypt não, vem no node:crypto memória e tempo quando não posso adicionar dependência nativa

Minha recomendação, assinada: em projeto novo, argon2id; em projeto que já roda com bcrypt, fique no bcrypt e suba o custo. Trocar de algoritmo por moda, com base de usuários viva, dá muito mais trabalho do que rende. E se dependência nativa for problema no seu deploy, o scrypt do node:crypto resolve sem instalar nada — só lembre que ali você guarda o salt e os parâmetros por conta própria, porque não existe a string pronta do bcrypt.

O que ainda falta proteger depois do hash

Hash bom não é login seguro. Repare no que a rota da PetVida ainda entrega, só medindo o tempo de resposta de dois logins que falharam igual:

bash
#!/bin/bash
# 5 logins que falham em cada caso, medindo só o tempo total da resposta
medir() {
  local soma=0
  for i in 1 2 3 4 5; do
    t=$(curl -s -o /dev/null -w '%{time_total}' -X POST localhost:3401/sessoes \
      -H 'Content-Type: application/json' \
      -d "{\"email\":\"$1\",\"senha\":\"chuteQualquer\"}")
    soma=$(node -p "$soma + $t")
  done
  node -p "'$1: ' + (($soma/5)*1000).toFixed(0) + ' ms em média'"
}
medir "ana@petvida.com.br"     # existe, senha errada
medir "bruno@petvida.com.br"   # não existe
ana@petvida.com.br: 206 ms em média bruno@petvida.com.br: 1 ms em média

As duas respostas são 401 com o mesmo texto, mas o relógio conta a verdade: quando o e-mail existe, o servidor gasta 206 ms rodando o compare; quando não existe, responde em 1 ms. Com isso um atacante descobre, em minutos, quais e-mails de uma lista são clientes da clínica. A mensagem igual não adiantou nada.

A correção é comparar sempre, exista o tutor ou não, contra um hash fantasma gerado no boot:

js
const HASH_FANTASMA = await bcrypt.hash('nenhum-tutor-usa-esta-senha', CUSTO);

app.post('/sessoes', async (req, res) => {
  const { email, senha } = req.body;
  const tutor = tutores.find((t) => t.email === email);

  const confere = await bcrypt.compare(senha, tutor?.senhaHash ?? HASH_FANTASMA);

  if (!tutor || !confere) return res.status(401).json({ erro: 'e-mail ou senha inválidos' });
  res.json({ id: tutor.id, email: tutor.email });
});

Rodando o mesmo script contra a versão corrigida:

ana@petvida.com.br: 201 ms em média bruno@petvida.com.br: 197 ms em média

201 contra 197 ms: o relógio parou de contar segredo. De quebra, esse desenho mata o data and hash arguments required da seção anterior, porque o compare sempre recebe um hash válido.

Ainda faltam três coisas que este artigo não cobre e que nenhum algoritmo de hash resolve: limite de tentativas por e-mail e por IP, para o atacante não poder chutar à vontade; política de senha que rejeite as óbvias, já que bruno123 cai em qualquer wordlist mesmo com custo 14; e HTTPS, porque a senha viaja em texto puro entre o navegador e a sua API.

O que vem depois

O hash resolve “esta pessoa é quem diz ser” uma vez, no login. Falta a segunda metade: manter isso de pé nas próximas requisições, sem pedir a senha de novo a cada clique. É o assunto de autenticação com JWT no Express, a próxima lição — e ela começa exatamente onde a rota /sessoes daqui parou, trocando o res.json do tutor por um token assinado.

Se quiser ver onde a autenticação entra no caminho completo do back-end, o guia de Node.js mostra a ordem inteira, do primeiro servidor ao deploy.

  • bcrypt
  • senha
  • hash
  • seguranca
  • node
  • autenticacao

Perguntas frequentes

Dá para descriptografar um hash bcrypt e recuperar a senha?
Não existe função inversa. O que existe é chute: o atacante testa milhões de senhas prováveis e vê qual bate. Por isso o custo importa tanto — ele define quantos chutes por segundo cabem no hardware de quem ataca.
Qual tamanho de coluna usar no banco para guardar o hash?
O hash do bcrypt tem sempre 60 caracteres ASCII. VARCHAR(60) basta, mas use VARCHAR(72) ou TEXT para não precisar de migration se um dia você trocar de algoritmo. Coluna curta demais trunca em silêncio e ninguém mais consegue logar.
bcrypt ou bcryptjs? Qual dos dois instalar?
O pacote bcrypt é nativo (compila em C++) e roda o hash fora da thread principal. O bcryptjs é JavaScript puro, não precisa de compilador e ajuda em ambientes serverless que rejeitam binário — em troca é mais lento e sempre síncrono por dentro. Os hashes são compatíveis entre os dois.
Posso gerar o hash no front-end antes de enviar a senha?
Não adianta. Se o hash é o que viaja, então o hash virou a senha — quem interceptar ele entra igual. Mande a senha por HTTPS e faça o hash no servidor, que é o único lugar onde ele significa alguma coisa.
E quando a pessoa esquece a senha, como faço para reenviar?
Você não reenvia, porque nem você sabe qual é. O fluxo é outro: gerar um token aleatório com validade curta, mandar por e-mail e, quando a pessoa voltar com ele, gravar um hash novo por cima do antigo.

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 com bcrypt 6.0.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. OWASP — Password Storage Cheat Sheet — cheatsheetseries.owasp.org
  2. npm — bcrypt — npmjs.com
  3. Node.js — crypto.scrypt — nodejs.org
  4. Provos e Mazières — A Future-Adaptable Password Scheme (USENIX 1999) — usenix.org

Continue por aqui