Quando você vai criptografar senha no Node.js, o cenário que você tem que ter na cabeça é o pior possível: o seu banco de dados vazou. Se as senhas estiverem lá em texto, o atacante entra no seu sistema como qualquer pessoa, e o estrago não para no seu site, porque boa parte das pessoas usa a mesma senha em todo lugar. Por isso a senha nunca é guardada como ela chegou, e sim como um hash, um valor derivado dela que não volta para a senha original. Para gerar esse hash vamos usar o Argon2, que agora já vem no módulo crypto do Node, sem instalar nenhuma biblioteca.

Um detalhe de nome antes de começar. Todo mundo fala em “criptografar a senha”, e eu também falo/falava, mas o que fazemos não é uma criptografia que você consegue desfazer depois com uma chave. A chave derivada (dk) não tem volta. Na hora do login você não descobre a senha que está no banco, você gera o hash de novo com a senha que a pessoa digitou e compara um com o outro.
Por que SHA-256 não serve para senha
Se você já usou o createHash com SHA-256 para guardar o id de uma sessão, você pode acabar pensando em usar o mesmo para a senha. Mas para senha ele não funciona, porque o SHA-256 é um algoritmo rápido. Com o banco vazado em mãos, o atacante consegue testar um volume enorme de senhas em pouco tempo, gerando o SHA-256 de cada uma e procurando qual bate com o que está no seu banco.
Tem um segundo problema, que aparece quando você roda o código:
import { createHash } from 'node:crypto';
const sha = createHash('sha256').update('12345678').digest('hex');
console.log(sha);
// sempre o mesmo valor, em qualquer máquina, toda vez que rodar
A mesma senha sempre gera o mesmo hash. Se eu olho um banco vazado e vejo um monte de usuários com exatamente o mesmo hash, já sei que eles têm a mesma senha, e provavelmente é algo como 12345678. Descobri a senha de um, descobri a de todos.
E nem precisa adivinhar. Existem as rainbow tables, que são tabelas prontas com o hash de senhas comuns. Pode ter certeza que o SHA-256 de 12345678 está numa delas, e o atacante só cruza a tabela com o seu banco para descobrir um monte de senhas fracas de uma vez. E senha fraca vai existir no seu site, não adianta. Você obriga a ter número e símbolo, e a pessoa cadastra P@ssw0rd, com o zero no lugar do “o”, ou Fl@m3ngo com o mesmo truque. O que queremos é que, mesmo com uma senha fraca dessas, o hash guardado seja impossível de reconhecer.
bcrypt, scrypt ou Argon2
Para senha a gente usa um algoritmo lento de propósito. A ideia é que, se o banco vazar, testar senha por força bruta demore tanto que simplesmente não valha a pena tentar.
As opções mais conhecidas são três. O bcrypt é antigo e muito usado. O scrypt está no node:crypto há muitos anos. E o Argon2, que venceu a Password Hashing Competition, é o que costuma ser apontado como o mais seguro dos três.
Por muito tempo, usar o Argon2 no Node significava instalar um pacote de terceiro fazendo a ponte com o código em C do algoritmo, e eu prefiro não depender de código de terceiro para uma coisa tão sensível. Por isso o scrypt era a escolha natural. Isso mudou na versão 24.7, quando o argon2 entrou no próprio node:crypto. Se o seu servidor roda o Node 24.7 ou mais novo, não tem motivo para não usar o Argon2, e é ele que vamos usar aqui.
O Argon2 tem três variantes: argon2d, argon2i e argon2id. Para senha, use o argon2id, que é a recomendada, porque junta a resistência das outras duas a ataques diferentes.
Criptografar senha no Node.js com Argon2
A versão mais crua do argon2 usa callback. Ela recebe o nome da variante, um objeto com os parâmetros e o callback, que recebe o erro e o valor gerado. Esse valor a gente chama de dk, de derived key (chave derivada), que é o nome que se dá ao hash gerado por esse tipo de algoritmo.
import { argon2 } from 'node:crypto';
const params = {
message: '12345678',
nonce: 'abcdefgh',
tagLength: 32,
parallelism: 4,
memory: 65536,
passes: 3,
};
argon2('argon2id', params, (err, dk) => {
console.log(dk.toString('hex'));
});
O message é a senha. O nonce é o salt, que vamos ver na próxima seção, e precisa ter pelo menos 8 bytes, senão o Node lança um erro. O tagLength é o tamanho da chave gerada, em bytes. Os outros três definem o custo do algoritmo.
O dk é um Buffer, por isso o toString('hex') para conseguir ler. Rode duas vezes e o resultado é o mesmo, e precisa ser, senão não teria como comparar a senha depois. Troque o nonce para qualquer outra coisa e o hash muda completamente, mesmo com a senha igual. É exatamente esse comportamento que resolve o problema das senhas repetidas.
Dá para transformar o argon2 numa função que retorna promise com o promisify, igual se faz com o randomBytes:
import { argon2, randomBytes } from 'node:crypto';
import { promisify } from 'node:util';
const randomBytesAsync = promisify(randomBytes);
const argon2Async = promisify(argon2);
Salt: a mesma senha com hashes diferentes
O salt é um valor aleatório combinado com a senha para que senhas idênticas resultem em hashes diferentes. Você “tempera” a senha com com o sal (salt). No argon2 ele vai no parâmetro nonce. No teste com callback usei 'abcdefgh' fixo, mas na prática ele é gerado de forma aleatória para cada pessoa, com o randomBytes. Um salt de 16 bytes já é suficiente, e é o tamanho que a própria documentação do Node recomenda.
const salt = await randomBytesAsync(16);
const dk = await argon2Async('argon2id', {
message: '12345678',
nonce: salt,
parallelism: 4,
tagLength: 32,
memory: 65536,
passes: 3,
});
console.log(dk.toString('hex'));
Agora, cada vez que você roda, sai um hash diferente para a mesma senha. Duas pessoas com 12345678 ficam com valores sem nenhuma relação no banco, e a rainbow table deixa de servir, porque ela teria que ter sido gerada com aquele salt específico.
Você pode se perguntar como a senha vai ser verificada depois, se o salt é aleatório. Ele é guardado junto com o hash. Sem o salt é impossível gerar de novo o mesmo dk, por isso o que vai para o banco é uma string com os dois, separados por um $:
const password_hash = `${salt.toString('hex')}$${dk.toString('hex')}`;
Uso hexadecimal de propósito, porque ele só tem caracteres de 0 a f. Assim o $ nunca aparece dentro do salt nem do dk, e fica fácil separar as partes depois. O tamanho também é previsível: os 16 bytes de salt viram 32 caracteres em hexadecimal, e os 32 bytes do dk (256 bits) viram 64.
Parâmetros memory, passes e parallelism do Argon2
O que deixa o Argon2 lento são três parâmetros:
const ARGON2_OPTIONS = {
memory: 2 ** 16, // memória em KiB (64 MiB)
passes: 3, // quantas vezes ele passa pela memória
parallelism: 4, // quantas linhas de processamento em paralelo
};
const dk = await argon2Async('argon2id', {
message: '12345678',
nonce: salt,
tagLength: 32,
...ARGON2_OPTIONS,
});
O memory é quanta memória cada hash ocupa, em blocos de 1 KiB. Então 2 ** 16, que é 65536, dá 64 MiB. É isso que torna o Argon2 caro de atacar: quem tenta força bruta com placa de vídeo ou hardware dedicado tem muito poder de processamento, mas não tem tanta memória para cada tentativa. O passes é quantas vezes o algoritmo percorre essa memória, e quanto maior, mais lento. O parallelism é quantas linhas ele processa em paralelo, e o memory precisa ser pelo menos 8 vezes esse valor.
Quanto tempo isso leva? Dá para medir com console.time:
console.time('argon2');
const dk = await argon2Async('argon2id', {
message: '12345678',
nonce: salt,
tagLength: 32,
...ARGON2_OPTIONS,
});
console.timeEnd('argon2');
Na minha máquina, com esses valores, deu por volta de 35 milissegundos. Não queremos que a função termine em 1 milissegundo, mas também não precisa demorar segundos, e algo abaixo de 100 milissegundos é um bom alvo. Só lembre que cada login ocupa os 64 MiB enquanto o hash é gerado, então num servidor pequeno com muitos logins ao mesmo tempo pode valer baixar o memory. O mínimo que a OWASP recomenda para o argon2id é 19 MiB (19456) com 2 passes e parallelism 1.
Se essa parte dos parâmetros ficou complicada, não se preocupe, ela é a menos importante para quem está desenvolvendo o Back End. O que você precisa entender é outra coisa: qualquer mudança nesses parâmetros muda o hash final. Com o salt e a senha fixos, passes: 3 gera um valor e passes: 2 gera outro completamente diferente. Se um dia você decidir aumentar a memória e trocar no código, todas as senhas que já estão no banco deixam de ser validadas. Esses parâmetros são fixos, e mais adiante eles vão para dentro da string salva no banco.
Pepper e normalização da senha
Com salt e Argon2 você já tem um hash de senha bem seguro. Dá para ir um pouco além com mais duas coisas: um pepper e a normalização da senha.
Pepper com o parâmetro secret
O pepper (pimenta, para combinar com o sal) é um segredo único que fica só no seu servidor e entra no cálculo junto com a senha. Diferente do salt, ele nunca é guardado no banco. Com o scrypt ou o bcrypt, o jeito é passar a senha antes por um createHmac com esse segredo, mas o Argon2 já tem um parâmetro para isso, o secret:
const PEPPER = 'segredo';
const dk = await argon2Async('argon2id', {
message: '12345678',
nonce: salt,
secret: PEPPER,
tagLength: 32,
...ARGON2_OPTIONS,
});
Se o banco vaza e o pepper não, o atacante não consegue chegar na senha nem com força bruta, porque falta um pedaço da receita. Em compensação, o pepper nunca pode mudar: trocar uma letra dele gera um hash completamente diferente e invalida todas as senhas cadastradas. Em produção ele vira um valor longo, guardado numa variável de ambiente, e não 'segredo' escrito no código.
Normalizar com normalize
Alguns caracteres, principalmente os acentuados, são visualmente iguais e diferentes nos bytes. O “é” pode ser um caractere só ou um “e” seguido de um acento combinado. Se a pessoa cadastrou a senha com uma codificação e o gerenciador de senhas manda a outra no login, o hash gerado é diferente e ela não consegue entrar.
const a = 'é';
const b = 'é';
console.log(a, b);
console.log(a.length, b.length); // 1 2
console.log(a === b);
console.log(a.normalize('NFC') === b.normalize('NFC'));O b foi escrito com o acento agudo combinado, logo depois do e, para a diferença não se perder quando você copia o código. As duas strings aparecem como “é”, mas o length mostra que a segunda tem dois caracteres, por isso a comparação direta dá false, e depois do normalize('NFC') ela dá true. Existem outras formas de normalização, mas para senha o NFC resolve, e você não precisa se aprofundar nelas.
Isso é uma questão de usabilidade. A normalização é a primeira coisa que acontece com a senha, antes do Argon2:
const NORM = 'NFC';
const dk = await argon2Async('argon2id', {
message: password.normalize(NORM),
nonce: salt,
secret: PEPPER,
tagLength: 32,
...ARGON2_OPTIONS,
});
Verificando a senha no login com timingSafeEqual
A pessoa se cadastrou, você gerou a string e salvou no banco. Agora ela tenta logar e você precisa saber se a senha que chegou é a mesma do cadastro. São três passos: fazer o parse da string salva para separar o salt e o dk, gerar o dk de novo com a senha nova e o salt guardado, e comparar os dois.
Antes, vale transformar a geração numa função:
async function hashPassword(password: string) {
const salt = await randomBytesAsync(16);
const dk = await argon2Async('argon2id', {
message: password.normalize(NORM),
nonce: salt,
secret: PEPPER,
tagLength: 32,
...ARGON2_OPTIONS,
});
return `${salt.toString('hex')}$${dk.toString('hex')}`;
}
O salt fica dentro da função, porque ele tem que ser novo a cada senha gerada.
O parse quebra a string no $ e transforma cada parte de volta em Buffer com o Buffer.from, indicando que o valor está em hexadecimal:
function parsePasswordHash(password_hash: string) {
const [stored_salt_hex, stored_dk_hex] = password_hash.split('$');
const stored_salt = Buffer.from(stored_salt_hex, 'hex');
const stored_dk = Buffer.from(stored_dk_hex, 'hex');
return { stored_salt, stored_dk };
}
Na verificação, a diferença mais importante é que você não gera salt nenhum. O salt usado é o que veio do banco, e é por isso que o dk sai igual ao original quando a senha está certa:
import { timingSafeEqual } from 'node:crypto';
async function verifyPassword(password: string, password_hash: string) {
const { stored_salt, stored_dk } = parsePasswordHash(password_hash);
const dk = await argon2Async('argon2id', {
message: password.normalize(NORM),
nonce: stored_salt,
secret: PEPPER,
tagLength: stored_dk.length,
...ARGON2_OPTIONS,
});
return timingSafeEqual(dk, stored_dk);
}
Por que timingSafeEqual e não uma comparação simples? Porque existem ataques que usam o tempo. Numa comparação comum, a resposta errada pode voltar mais rápido do que a certa, e medindo quanto tempo cada tentativa leva o atacante vai ganhando pistas sobre a senha. O timingSafeEqual compara em tempo constante, então a resposta demora o mesmo para senha certa e errada.
Repare no tagLength: stored_dk.length. O timingSafeEqual lança uma exceção quando os dois Buffers têm tamanhos diferentes, e pedindo ao Argon2 um dk do mesmo tamanho do que está salvo, os dois sempre chegam iguais na comparação.
Para testar, gere o hash de uma senha e verifique com ela e com outra:
const password = 'P@ssw0rd';
const password_hash = await hashPassword(password);
const isTrue = await verifyPassword(password, password_hash);
const isFalse = await verifyPassword('12345678', password_hash);
console.log({ isTrue, isFalse });
// { isTrue: true, isFalse: false }
Guardando as configurações na password string
Neste ponto já daria para parar, e você teria um hash de senha muito bom. Só que lembra que qualquer mudança nos parâmetros altera o hash? Imagine que, daqui a dois anos, ninguém lembra se as senhas foram geradas com 64 MiB ou 128 MiB de memória. Vale guardar as configurações dentro da própria string.
O formato que eu uso é inspirado no PHC string format, criado na mesma Password Hashing Competition de onde saiu o Argon2. Cada campo continua separado por $, e os parâmetros usam as letras do formato: m para memória, t para passes e p para parallelism.
argon2id$v=1$norm=NFC$m=65536,t=3,p=4$<salt>$<dk>
Primeiro vem o algoritmo, depois a versão, que serve para você mesmo. Se no futuro quiser parâmetros mais fortes, gera as senhas novas como v=2 e o seu código passa a saber comparar as duas versões. Depois vêm a normalização (se ela mudar, o hash muda), os parâmetros separados por vírgula, o salt e o dk. A string não começa com $, porque senão o primeiro item do split seria uma string vazia e você teria que filtrar.
O hashPassword passa a montar a string completa, e o parse fica um pouco maior. A normalização sai do norm= com um replace, e os parâmetros voltam a ser um objeto com o Object.fromEntries, quebrando cada chave=valor no = e convertendo o valor com Number, porque o Argon2 espera números:
async function hashPassword(password: string) {
const salt = await randomBytesAsync(16);
const dk = await argon2Async('argon2id', {
message: password.normalize(NORM),
nonce: salt,
secret: PEPPER,
tagLength: 32,
...ARGON2_OPTIONS,
});
const { memory, passes, parallelism } = ARGON2_OPTIONS;
return (
`argon2id$v=1$norm=${NORM}$m=${memory},t=${passes},p=${parallelism}` +
`$${salt.toString('hex')}$${dk.toString('hex')}`
);
}
function parsePasswordHash(password_hash: string) {
const [id, v, norm, options, stored_salt_hex, stored_dk_hex] =
password_hash.split('$');
const stored_salt = Buffer.from(stored_salt_hex, 'hex');
const stored_dk = Buffer.from(stored_dk_hex, 'hex');
const stored_norm = norm.replace('norm=', '');
const { m, t, p } = Object.fromEntries(
options.split(',').map((kv) => {
const [k, v] = kv.split('=');
return [k, Number(v)];
}),
);
const stored_options = { memory: m, passes: t, parallelism: p };
return { stored_options, stored_norm, stored_salt, stored_dk };
}
No verifyPassword, a normalização e os parâmetros passam a vir da string, e não mais das constantes globais:
async function verifyPassword(password: string, password_hash: string) {
const { stored_options, stored_norm, stored_salt, stored_dk } =
parsePasswordHash(password_hash);
const dk = await argon2Async('argon2id', {
message: password.normalize(stored_norm),
nonce: stored_salt,
secret: PEPPER,
tagLength: stored_dk.length,
...stored_options,
});
return timingSafeEqual(dk, stored_dk);
}
Em segurança nada mudou, isso é apenas para melhorar a usabilidade de quem desenvolve. Você pode pensar que escrever “argon2id” e os parâmetros na string facilita para o atacante, mas não facilita, porque existe a segurança de verdade, que é usar um algoritmo forte mesmo com a pessoa sabendo qual é, e existe a segurança de caixa preta (black box), que só funciona enquanto ninguém sabe o que tem dentro. Segurança por obscuridade geralmente não é a melhor que existe, porque as pessoas vão testando até descobrir. Esconder se você usou Node ou PHP afasta no máximo o atacante bem amador. O que protege a senha é o hash ser forte.
Antes de juntar tudo, vale ver o caminho completo de cada função. No cadastro, a senha normalizada, o salt, o pepper e os parâmetros entram no Argon2, e o que vai para o banco é a password string:
No login, o salt e os parâmetros vêm da password string, o Argon2 gera o dk de novo e o timingSafeEqual compara com o que estava salvo:
Para usar isso numa API, eu junto tudo numa classe Password, que recebe o pepper no construtor e deixa os tamanhos do salt e do dk como propriedades, para você mudar num lugar só se quiser:
import { argon2, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const randomBytesAsync = promisify(randomBytes);
const argon2Async = promisify(argon2);
export class Password {
PEPPER: string;
NORM = 'NFC';
ARGON2_OPTIONS = {
memory: 2 ** 16,
passes: 3,
parallelism: 4,
};
DK_LEN = 32;
SALT_LEN = 16;
constructor(PEPPER: string) {
this.PEPPER = PEPPER;
}
async hash(password: string) {
const salt = await randomBytesAsync(this.SALT_LEN);
const dk = await argon2Async('argon2id', {
message: password.normalize(this.NORM),
nonce: salt,
secret: this.PEPPER,
tagLength: this.DK_LEN,
...this.ARGON2_OPTIONS,
});
const { memory, passes, parallelism } = this.ARGON2_OPTIONS;
return (
`argon2id$v=1$norm=${this.NORM}$m=${memory},t=${passes},p=${parallelism}` +
`$${salt.toString('hex')}$${dk.toString('hex')}`
);
}
parse(password_hash: string) {
const [id, v, norm, options, stored_salt_hex, stored_dk_hex] =
password_hash.split('$');
const stored_salt = Buffer.from(stored_salt_hex, 'hex');
const stored_dk = Buffer.from(stored_dk_hex, 'hex');
const stored_norm = norm.replace('norm=', '');
const { m, t, p } = Object.fromEntries(
options.split(',').map((kv) => {
const [k, v] = kv.split('=');
return [k, Number(v)];
}),
);
const stored_options = { memory: m, passes: t, parallelism: p };
return { stored_options, stored_norm, stored_salt, stored_dk };
}
async verify(password: string, password_hash: string) {
const { stored_options, stored_norm, stored_salt, stored_dk } =
this.parse(password_hash);
const dk = await argon2Async('argon2id', {
message: password.normalize(stored_norm),
nonce: stored_salt,
secret: this.PEPPER,
tagLength: stored_dk.length,
...stored_options,
});
return timingSafeEqual(dk, stored_dk);
}
}
O uso fica em duas linhas, uma no cadastro e outra no login:
const pass = new Password('segredo');
const password_hash = await pass.hash('P@ssw0rd');
const isTrue = await pass.verify('P@ssw0rd', password_hash);
const isFalse = await pass.verify('12345678', password_hash);
A partir daqui, a segurança das senhas depende de duas coisas que você não pode perder nem trocar: o pepper, que deve ficar guardado fora do banco, e os parâmetros usados para gerar as senhas que já existem. O código pode ser reescrito sempre que necessário, mas, se o pepper se perder, ninguém mais consegue fazer login no sistema.