Blog

Hash de senha: bcrypt, scrypt, Argon2 e os parâmetros que importam.

Guardar senha tem uma regra que todo mundo sabe: não guarde a senha, guarde o hash.

O que se sabe menos é que o hash certo com parâmetro errado protege quase tão pouco quanto não ter hash. E parâmetro é o que ninguém revisa, porque foi copiado da documentação em 2019 e nunca mais alguém olhou.

Por que SHA-256 não serve

SHA-256 é excelente para verificar integridade. É rápido: uma GPU moderna calcula bilhões por segundo.

Essa velocidade é exatamente o problema. Se o seu banco vazar e as senhas estiverem em SHA-256, um atacante testa a lista das dez mil senhas mais comuns contra todos os usuários em segundos. Depois parte pra ataque de dicionário com regras, e depois pra força bruta nas senhas curtas.

Função de senha é deliberadamente lenta. É a única propriedade que importa aqui, e é a oposta da que faz uma função de hash ser boa em qualquer outro contexto. Escrevi sobre a diferença em o que é hash criptográfico.

Os três candidatos

bcrypt é de 1999 e continua sendo uma escolha defensável. Testado à exaustão, disponível em toda linguagem, difícil de configurar errado. Duas limitações: usa pouca memória, o que o torna vulnerável a hardware dedicado, e trunca a senha em 72 bytes. Senha longa com emoji pode passar disso, e o excedente é silenciosamente ignorado.

scrypt trouxe a ideia de custo de memória, que é o que atrapalha ataque com GPU e ASIC. Bom algoritmo, configuração mais confusa.

Argon2id venceu a competição de hashing de senha em 2015 e é a recomendação atual do OWASP. Custo de memória, custo de tempo e paralelismo configuráveis, com resistência a ataque de canal lateral. É o que eu uso em projeto novo.

Se o seu sistema já usa bcrypt com custo adequado, não migre por migrar. Ninguém foi comprometido por usar bcrypt bem configurado.

Os parâmetros

Para Argon2id, a referência do OWASP em configuração de memória mais alta:

import { hash, verify } from "@node-rs/argon2";

const OPTIONS = {
  memoryCost: 19456,   // 19 MiB
  timeCost: 2,         // iterações
  parallelism: 1,
};

export const hashPassword = (plain: string) => hash(plain, OPTIONS);
export const checkPassword = (plain: string, stored: string) =>
  verify(stored, plain, OPTIONS);

Para bcrypt, o custo mínimo aceitável hoje é 12. Custo 10, que era o padrão de muitas bibliotecas por anos, ficou barato demais: cada incremento dobra o trabalho, então 10 é quatro vezes mais fácil de atacar que 12.

O jeito de calibrar é empírico. Meça no seu hardware de produção e ajuste até o hash levar entre 250 e 500 milissegundos. Menos que isso é fraco. Muito mais que isso vira vetor de negação de serviço, porque cada tentativa de login consome CPU sua.

E anote: esse número precisa ser revisado a cada dois anos. Hardware fica mais rápido, o parâmetro precisa acompanhar. Coloque na agenda, porque ninguém lembra sozinho.

Sal, pimenta e a confusão entre os dois

Sal é aleatório, único por usuário, e não é secreto. Ele impede que dois usuários com a mesma senha tenham o mesmo hash, o que inutiliza tabela pré-computada. bcrypt e Argon2 geram e embutem o sal no resultado automaticamente. Você não precisa fazer nada, e não deve tentar fazer nada.

Pimenta é um segredo global, igual pra todos, guardado fora do banco. Se o banco vazar sem a aplicação, os hashes ficam inúteis.

const peppered = createHmac("sha256", process.env.PASSWORD_PEPPER).update(plain).digest();
const stored = await hash(peppered, OPTIONS);

A pimenta é opcional e traz uma complicação real: rotacionar ela exige rehash de todo mundo, o que só dá pra fazer no próximo login de cada usuário. Vale a pena quando o modelo de ameaça inclui vazamento só do banco, que é o cenário mais comum de todos.

Migrar sem forçar troca de senha

Você está em bcrypt custo 10 e quer ir pra Argon2id. Não precisa pedir pra ninguém trocar senha.

async function login(email: string, plain: string) {
  const user = await users.findByEmail(email);
  if (!user) {
    await fakeVerify();          // gasta o mesmo tempo, evita enumeração
    throw new UnauthorizedException();
  }

  const ok = user.passwordHash.startsWith("$2")
    ? await bcrypt.compare(plain, user.passwordHash)
    : await checkPassword(plain, user.passwordHash);

  if (!ok) throw new UnauthorizedException();

  // acertou: aproveita que tem a senha em mãos e regrava no formato novo
  if (user.passwordHash.startsWith("$2")) {
    await users.updateHash(user.id, await hashPassword(plain));
  }

  return issueTokens(user);
}

Em alguns meses a maior parte da base migrou sozinha. O resto você trata quando fizer sentido.

O fakeVerify merece atenção. Sem ele, o login com e-mail inexistente responde muito mais rápido que o login com e-mail existente e senha errada, e essa diferença de tempo permite enumerar quem tem conta. Gastar o mesmo tempo nos dois caminhos fecha isso.

O que mais protege que o algoritmo

Sendo honesto: o algoritmo importa depois do vazamento. Antes dele, outras coisas importam mais.

Bloquear senha vazada. A API do Have I Been Pwned verifica com k-anonimato, sem enviar a senha. Uma senha que já está em vazamento conhecido é quebrada em qualquer algoritmo, porque ela está na primeira linha da lista.

Rate limit no login, por IP e por conta. Sem isso, o atacante nem precisa do seu banco.

Segundo fator. Muda o problema de categoria.

E não exigir troca periódica de senha nem regra de complexidade barroca. As duas coisas empurram o usuário pra Empresa2026! e pro post-it no monitor. Comprimento mínimo generoso e verificação contra lista de vazadas funcionam muito melhor.

Sobre o resto do fluxo de autenticação, escrevi em JWT: os erros que aparecem em quase todo projeto.

← Voltar pro blog