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.