# Como criptografar senhas no Node.js com Argon2, salt e pepper

Por que SHA-256 apenas não serve para senha e como gerar o hash com Argon2, salt e pepper usando o crypto nativo do Node.js, sem instalar biblioteca.

Curso: [Back End Node.js](https://www.origamid.com/curso/back-end-node-js/) · Aula: [password](https://www.origamid.com/curso/back-end-node-js/0510-password/)

URL: https://www.origamid.com/artigos/senha-nodejs-argon2/

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.

*Figura: Esquema do hash da senha: a senha '12345678', o salt gerado com randomBytes(16) e o pepper 'segredo' entram no argon2('argon2id', ARGON2_OPTIONS). Ele gera o dk (chave derivada), que vai para o banco na password string argon2id$v=1$norm=NFC$m=65536,t=3,p=4$\<salt>$\<dk>.*

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:

```js title="crypto.js"
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.

```js title="crypto.js"
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`:

```ts title="crypto.ts"
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.

```ts title="crypto.ts"
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 `$`:

```ts title="crypto.ts"
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:

```ts title="crypto.ts"
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`:

```ts title="crypto.ts"
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`:

```ts title="crypto.ts"
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.

```js
const a = 'é';
const b = 'é';

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:

```ts title="crypto.ts"
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:

```ts title="crypto.ts"
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:

```ts title="crypto.ts"
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:

```ts title="crypto.ts"
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:

```ts title="crypto.ts"
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:

```ts title="crypto.ts"
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:

```ts title="crypto.ts"
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:

```ts title="password.ts"
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:

```ts
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.

## Referências

- [Crypto | Node.js v26.11.1 Documentation](https://nodejs.org/api/crypto.html)
- [String.prototype.normalize() - JavaScript | MDN](https://developer.mozilla.org/pt-BR/docs/Web/JavaScript/Reference/Global_Objects/String/normalize)
- [Password Storage - OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
- [The PHC string format - C2SP](https://c2sp.org/phc-strings)

Quer aprender mais? Conheça o curso [Back End Node.js](https://www.origamid.com/curso/back-end-node-js/).
