Pular para o conteúdo
19 min de leitura

Autenticação segura: hashing de senha, salt e MFA explicados para desenvolvedores

Por Lucas Andrade ·

Como guardar credenciais e autenticar de forma correta: por que hash lento, salt, pepper, MFA e CSPRNG decidem o estrago de um vazamento.

Neste artigo

Imagine a manhã em que o dump do seu banco de dados aparece à venda num fórum. A tabela users inteira, com e-mail, nome e a coluna de senha. A partir desse instante, a única coisa que separa os seus usuários de uma onda de invasões de conta — no seu serviço e em todos os outros onde eles reusaram a mesma senha — é a forma como você guardou aquela senha. Não é o firewall que caiu, não é o WAF, não é a política de acesso do banco: essas defesas já falharam, por definição, no momento em que o dump vazou. O que resta é a matemática. Se você guardou texto puro, o atacante tem tudo instantaneamente. Se guardou um hash rápido, ele tem tudo em horas. Se guardou com uma KDF lenta, salt por usuário e uma política de senha decente, ele tem um punhado de contas fracas e o resto continua protegido por semanas ou anos de custo computacional que não vale a pena pagar.

Este artigo é sobre essa decisão e todas as que a acompanham. Não é sobre OAuth 2.0 nem sobre delegação de acesso entre serviços — aqui o assunto é a credencial de primeira mão: a senha que o usuário digita, como ela vira um registro no seu banco, como você a verifica sem nunca reconstruí-la, e como você adiciona fatores que sobrevivem mesmo quando a senha cai. Também não é sobre gestão de secrets de infraestrutura; é sobre o segredo que pertence ao usuário.

Texto puro e criptografia reversível: os dois erros que definem o desastre#

O primeiro princípio, o mais elementar e o mais violado, é que você nunca precisa saber a senha do usuário. Você só precisa provar que quem está logando conhece a mesma senha que cadastrou. Isso muda tudo, porque significa que a senha nunca deveria existir em forma recuperável no seu sistema depois do cadastro.

Guardar em texto puro é o pecado óbvio: qualquer pessoa com acesso de leitura ao banco — um dump vazado, um backup mal guardado, um estagiário curioso, um log que registrou o corpo do request — tem a credencial pronta para uso. Mas existe um erro mais sutil e igualmente grave: guardar a senha criptografada de forma reversível. Desenvolvedores às vezes acham que "criptografar a senha com AES" é a solução responsável. Não é. Criptografia é uma transformação reversível: existe uma chave que desfaz a operação e devolve o texto original. Se o seu servidor consegue decifrar a senha para compará-la, então a chave está em algum lugar que o servidor alcança — variável de ambiente, secret manager, arquivo de configuração — e um atacante que comprometeu o banco a ponto de dumpar a tabela de senhas quase sempre também alcança essa chave. Você trocou "texto puro" por "texto puro com um passo a mais".

A ferramenta correta é o hash criptográfico, que é uma função unidirecional. Dado o input, ela produz uma saída de tamanho fixo; dado a saída, não existe caminho de volta a não ser tentar todos os inputs possíveis. Você guarda o hash da senha. No login, você aplica a mesma função à senha digitada e compara os hashes. Em nenhum momento o texto original é armazenado ou reconstruível. A diferença entre criptografia e hash não é acadêmica: é a diferença entre um sistema onde existe uma chave capaz de revelar todas as senhas e um sistema onde essa chave simplesmente não existe. Para senha, a irreversibilidade não é um efeito colateral — é o requisito.

Por que <code>MD5</code> e <code>SHA-256</code> estão errados para senha#

Estabelecido que precisamos de um hash, o instinto natural é pegar um hash famoso: MD5, SHA-1, SHA-256. Todos são funções unidirecionais, todos produzem saída de tamanho fixo. E todos são a escolha errada para senha — por um motivo que inverte completamente o que os torna bons em outros contextos.

Essas funções foram projetadas para serem rápidas. Um hash de integridade de arquivo precisa processar gigabytes em segundos; SHA-256 é otimizado exatamente para isso, e o hardware moderno o executa a uma velocidade brutal. Uma GPU comum calcula bilhões de hashes SHA-256 por segundo. Um cluster dedicado, ordens de magnitude mais. Quando você guarda a senha como SHA-256, você entrega ao atacante um problema que a indústria inteira passou décadas otimizando para resolver depressa. Ele pega uma lista das senhas mais comuns, gera os hashes e compara com o seu dump — não em anos, em minutos. Depois parte para ataques de dicionário com mutações, e depois para força bruta pura sobre senhas curtas. A velocidade que faz SHA-256 excelente para checksums é exatamente o que o torna inútil para proteger senhas.

O que precisamos é o oposto: uma função deliberadamente lenta, com custo ajustável. Isso é uma KDF — Key Derivation Function — ou, mais especificamente para este uso, uma função de hashing de senha. Ela é construída para ser cara de calcular: onde SHA-256 faz uma passada, a KDF faz milhares de iterações internas, ou consome uma quantidade grande de memória, ou ambos. O objetivo é que verificar uma senha (o que seu servidor faz a cada login legítimo) custe alguns milissegundos — imperceptível para o usuário — enquanto testar bilhões de senhas (o que o atacante precisa fazer) se torne proibitivamente caro. E o "quanto caro" é um parâmetro que você aumenta com o tempo, acompanhando a evolução do hardware. É essa capacidade de ajustar o custo para cima, indefinidamente, que separa uma KDF de um hash de propósito geral.

<code>bcrypt</code>, <code>scrypt</code> e <code>argon2id</code>: as três funções que você realmente deve usar#

Três famílias dominam o hashing de senha correto, e vale entender o que cada uma oferece.

O bcrypt é o veterano confiável, derivado da cifra Blowfish e em uso há mais de duas décadas. Seu parâmetro central é o cost factor (ou work factor), um expoente: cada incremento de uma unidade dobra o número de iterações internas. Um cost de 12 significa 2¹² rounds. A recomendação da casa, e um piso sensato hoje, é cost ≥ 12; conforme o hardware melhora, você sobe para 13, 14 e assim por diante. O bcrypt tem duas limitações que valem menção: ele só considera os primeiros 72 bytes da senha (irrelevante para a esmagadora maioria dos casos, mas real) e seu custo é puramente computacional — não força o atacante a gastar memória, o que o deixa mais exposto a ataques com hardware especializado do que as alternativas mais novas.

O scrypt foi a resposta a essa lacuna: além do custo de CPU, ele é memory-hard, ou seja, exige uma quantidade configurável de memória para calcular cada hash. Isso ataca diretamente a vantagem das GPUs e dos ASICs, que têm muito poder de cálculo mas memória limitada e cara por núcleo. Forçar consumo de memória nivela o campo entre o seu servidor e o hardware de ataque.

O recomendado hoje, quando você está começando do zero, é o argon2id. Vencedor da Password Hashing Competition, ele combina três eixos de custo independentes: tempo (número de iterações), memória (quanto de RAM cada hash consome) e paralelismo (quantas lanes de cálculo). A variante id é um híbrido que resiste tanto a ataques de canal lateral por tempo (herança do Argon2i) quanto a ataques com trade-off de memória-tempo (herança do Argon2d) — por isso é a variante indicada para senhas. Você calibra os parâmetros para que um hash consuma, digamos, algumas dezenas de megabytes de memória e alguns milissegundos de tempo no seu hardware; um atacante querendo paralelizar milhões de tentativas precisa multiplicar aquela memória toda por cada tentativa simultânea, o que rapidamente sai da conta. A regra prática para qualquer uma das três: meça no seu servidor de produção, escolha parâmetros que levem o login legítimo a algo entre 100 e 500 milissegundos, e reavalie periodicamente para cima. Um detalhe operacional importante: essas funções embutem os parâmetros e o salt na própria string de saída, então você consegue rehashear as senhas com custo maior de forma transparente, na próxima vez que cada usuário logar.

Salt e pepper: dois segredos que resolvem problemas diferentes#

Mesmo com uma KDF lenta, falta um ingrediente. Se dois usuários têm a mesma senha e você aplica a mesma função sem mais nada, eles produzem o mesmo hash. Pior: um atacante pode pré-computar os hashes das senhas mais prováveis uma única vez, numa tabela gigante — uma rainbow table — e depois consultar o seu dump inteiro contra ela instantaneamente, transformando o custo da KDF em irrelevante porque ele paga esse custo uma vez só para todo mundo.

O salt resolve isso. É um valor aleatório, único por usuário, gerado no cadastro e concatenado à senha antes do hashing. Ele não é secreto — fica guardado ao lado do hash, em texto claro, e tudo bem que fique. Seu propósito não é sigilo, é unicidade: com um salt diferente por conta, senhas idênticas produzem hashes diferentes, tabelas pré-computadas se tornam inúteis (elas teriam que ser refeitas para cada salt), e o atacante é forçado a atacar cada senha individualmente, pagando o custo integral da KDF por tentativa e por usuário. Um salt precisa ter entropia suficiente (16 bytes de um gerador seguro é o padrão) e ser único de verdade — as funções bcrypt, scrypt e argon2id geram e embutem o salt automaticamente, então na prática você não deve inventar esse mecanismo à mão.

O pepper é um segredo adicional que resolve um problema diferente e complementar. Enquanto o salt vive ao lado do hash no banco, o pepper é um segredo do servidor, único para toda a aplicação, guardado fora do banco de dados — num secret manager, num HSM, numa variável de ambiente do serviço de auth. Ele entra no cálculo do hash (por exemplo, como uma chave num HMAC aplicado antes ou depois da KDF). A ideia é a defesa em profundidade: se o atacante dumpa apenas o banco — o cenário de vazamento mais comum, via SQL injection ou backup exposto — ele tem os hashes e os salts, mas não tem o pepper, e sem o pepper nenhuma tentativa de força bruta produz o hash correto. O salt protege contra pré-computação e contra colisões entre usuários; o pepper protege contra o vazamento isolado do banco. Eles não se substituem. O ponto crítico do pepper é que ele não pode estar armazenado no mesmo lugar que os hashes, senão perde toda a razão de existir.

Comparação em tempo constante: o vazamento que ninguém vê#

Verificar a senha significa comparar dois valores: o hash recém-calculado e o hash guardado. Parece trivial, mas a comparação ingênua esconde um vazamento. Uma comparação de strings comum retorna assim que encontra o primeiro byte diferente — é uma otimização natural. Isso significa que comparar dois valores que diferem no primeiro byte é microscopicamente mais rápido do que comparar dois que só diferem no último. Essa diferença de tempo, medida sobre muitas tentativas, pode revelar quanto de um valor o atacante já acertou. É a classe dos timing attacks.

Para hashes de senha completos o risco prático é menor, porque o atacante não controla diretamente o hash-alvo; mas em tokens, chaves de API e códigos de verificação — onde ele envia o valor e mede a resposta — o timing attack é concreto e explorável. A defesa é sempre a mesma e barata: use comparação em tempo constante, uma função que percorre todos os bytes independentemente de onde estejam as diferenças, gastando o mesmo tempo tanto para valores idênticos quanto para valores que diferem já no início. As bibliotecas de cada linguagem oferecem isso (o típico constant_time_compare / timingSafeEqual / subtle.ConstantTimeCompare). A regra: nunca compare segredos com o operador de igualdade padrão da linguagem. Trate toda comparação de material sensível como constante-no-tempo por default.

Política de senha moderna: o que o NIST 800-63B realmente recomenda#

Boa parte do que se considerava "senha forte" há dez anos hoje é reconhecido como contraproducente. As diretrizes do NIST 800-63B reorganizaram o campo em torno de uma ideia central: comprimento vence complexidade forçada. Uma frase longa e memorável tem muito mais entropia real do que oito caracteres recheados de símbolos que o usuário mal consegue lembrar — e que ele acaba anotando num post-it ou reciclando entre serviços.

Na prática isso significa: exija um comprimento mínimo generoso (oito é o piso absoluto, doze ou mais é melhor) e permita comprimentos longos de verdade, aceitando frases inteiras — o limite superior existe só para evitar abuso de DoS no hashing, e deve ser alto. Aceite qualquer caractere Unicode imprimível, incluindo espaços e emoji, sem regras arbitrárias de "pelo menos um número, uma maiúscula e um símbolo": essas regras empurram todo mundo para os mesmos padrões previsíveis (Senha1!) e reduzem a entropia real em vez de aumentá-la. O ganho verdadeiro vem de checar a senha escolhida contra listas de senhas vazadas — o serviço HIBP (Have I Been Pwned) expõe uma API que permite verificar se uma senha já apareceu em vazamentos conhecidos sem enviar a senha em claro, usando k-anonymity sobre um prefixo do hash. Se a senha está numa lista pública de milhões de credenciais comprometidas, ela é fraca por definição, não importa quantos símbolos tenha. Rejeite-a e explique o porquê.

Igualmente importante é o que você não deve fazer: não force expiração periódica arbitrária ("troque a senha a cada 90 dias"). Essa prática, antes universal, hoje é desaconselhada porque produz senhas piores — os usuários fazem mutações previsíveis (Senha1, Senha2, Senha3) e a rotação forçada não corresponde a nenhuma ameaça real. Troque a senha quando houver evidência de comprometimento, não no calendário. Não imponha dicas de senha nem perguntas de segurança recuperáveis ("nome do primeiro pet"), que são essencialmente senhas fracas e públicas. E ofereça a possibilidade de exibir a senha durante a digitação, o que reduz erros e permite senhas mais longas sem frustração.

MFA: quando a senha cai, o segundo fator segura#

Toda a discussão acima trata de manter a senha protegida. Mas a premissa realista da segurança moderna é que a senha vai vazar — por phishing, por reuso, por um vazamento em outro serviço. É por isso que a autenticação de dois fatores deixou de ser luxo e virou piso. A ideia é combinar fatores de naturezas diferentes: algo que você sabe (a senha), algo que você tem (um dispositivo, uma chave) e algo que você é (biometria). Um atacante que rouba a senha ainda não tem o segundo fator.

Nem todos os segundos fatores são iguais, e a hierarquia importa. O TOTP (Time-based One-Time Password) — aqueles códigos de seis dígitos que giram a cada trinta segundos num app autenticador — funciona a partir de um segredo compartilhado: no momento em que você ativa o TOTP, servidor e app guardam a mesma semente aleatória. A cada intervalo de tempo, ambos combinam essa semente com o horário atual (o timestamp dividido pela janela de 30 segundos) através de um HMAC e derivam o mesmo código de seis dígitos, sem precisar se comunicar. O servidor confere se o código enviado bate com o que ele calculou para a janela corrente (tolerando uma ou duas janelas de defasagem de relógio). É simples, offline e muito melhor que só senha — mas o segredo compartilhado pode ser interceptado no cadastro, e, crucialmente, o TOTP é phishável: um site falso pede o código, o usuário digita, e o atacante o repassa ao site real dentro da janela de validade.

As notificações push (aprovar/negar no celular) melhoram a experiência mas sofrem de fadiga de aprovação — o usuário bombardeado de prompts acaba aceitando um que não iniciou. O SMS é o fator mais fraco de todos e deve ser evitado como fator primário: números são sequestráveis por SIM swap, as mensagens trafegam por uma rede sem garantias de entrega segura, e são interceptáveis. SMS é melhor que nada, mas é o piso do piso.

No topo da hierarquia estão o WebAuthn e as passkeys, e a diferença é qualitativa, não incremental. Baseadas em criptografia de chave pública, elas guardam uma chave privada no dispositivo do usuário (protegida por biometria ou PIN, muitas vezes num elemento de hardware) e registram a chave pública no servidor. A autenticação é um desafio-resposta assinado. O ponto decisivo: a assinatura é vinculada à origem — ao domínio real do site. Um site de phishing tem outra origem, e o autenticador simplesmente se recusa a assinar para o domínio errado. Isso torna passkeys resistentes a phishing por construção, não por vigilância do usuário. Não há segredo compartilhado que possa ser interceptado, não há código que o usuário possa ser enganado a digitar num site falso. Quando o objetivo é segurança séria, passkeys/WebAuthn são o destino, com TOTP como fallback e SMS reservado só para casos onde nada melhor é possível.

Tokens e sessão: CSPRNG, nunca <code>Math.random()</code>#

Autenticar com sucesso é só o começo; depois disso o usuário carrega uma sessão, e a sessão é tão atacável quanto a senha. Um identificador de sessão adivinhável é uma porta dos fundos que ignora toda a criptografia de senha do mundo. Por isso todo token de sessão, token de reset de senha, código de verificação e chave de API precisa vir de um CSPRNG — um gerador de números pseudoaleatórios criptograficamente seguro.

O erro clássico e fatal é usar Math.random() (ou o equivalente não-seguro de cada linguagem) para gerar segredos. Math.random() é projetado para velocidade e distribuição estatística, não para imprevisibilidade: seu estado interno pode ser reconstruído a partir de algumas saídas observadas, permitindo prever os próximos valores — e portanto forjar tokens de sessão de outros usuários. Use sempre a fonte criptográfica da plataforma (crypto.randomBytes, crypto.getRandomValues, secrets em Python, crypto/rand em Go, /dev/urandom), com entropia suficiente — 128 bits é o mínimo confortável para um identificador de sessão. A regra é categórica: nenhum segredo nasce de um gerador não-criptográfico.

Onde a sessão vive também importa. O token de sessão deve ir para o cliente num cookie marcado como HttpOnly (inacessível ao JavaScript da página, o que neutraliza o roubo de sessão via XSS), Secure (só trafega sobre HTTPS, nunca em claro) e SameSite (Lax ou Strict, para mitigar CSRF impedindo que o cookie acompanhe requisições cross-site indesejadas). Nunca guarde o token de sessão em localStorage ou sessionStorage: esses storages são plenamente acessíveis a qualquer script na página, então uma única falha de XSS exfiltra a sessão inteira. O cookie HttpOnly é a diferença entre um XSS que rouba a sessão e um XSS contido. Sessões devem ter tempo de vida razoável, ser renováveis, e o logout precisa invalidar a sessão no servidor — não basta apagar o cookie no cliente, o registro correspondente tem que morrer no backend para que um token capturado antes do logout não continue valendo.

Protegendo o fluxo: rate limit, respostas genéricas e reset seguro#

A criptografia protege a senha em repouso; o desenho dos fluxos protege a senha em uso. O login precisa de rate limit e de alguma forma de lockout ou desaceleração progressiva: sem isso, um atacante testa senhas contra uma conta indefinidamente (credential stuffing e força bruta online). Limite tentativas por conta e por origem, introduza atrasos crescentes, e considere CAPTCHA ou step-up após um número de falhas. O cuidado é calibrar para não transformar o lockout numa arma de negação de serviço contra usuários legítimos — desaceleração e detecção de anomalia costumam ser melhores que bloqueio duro.

Um detalhe frequentemente ignorado: as respostas de erro não podem vazar se um e-mail existe. Se o login responde "senha incorreta" quando o e-mail existe e "usuário não encontrado" quando não existe, você entregou ao atacante um oráculo de enumeração de contas — ele descobre quais e-mails têm cadastro antes mesmo de atacar as senhas. A mesma armadilha vale para o fluxo de "esqueci minha senha" e para o cadastro. A resposta deve ser genérica e idêntica nos dois casos: "se este e-mail estiver cadastrado, enviaremos as instruções". E o tempo de resposta também precisa ser consistente, para não vazar a existência da conta pelo relógio.

O reset de senha é, ele próprio, um caminho de autenticação e merece o mesmo rigor. O token de reset tem que vir de um CSPRNG, ser de uso único, ter validade curta (minutos a poucas horas), ser invalidado assim que usado ou assim que um novo é solicitado, e não deve ser previsível nem reutilizável. Idealmente ele é guardado no servidor como um hash — se o banco vazar, os tokens de reset em trânsito não devem ser utilizáveis diretamente. Ao concluir um reset, invalide todas as sessões ativas daquela conta: se o motivo do reset foi comprometimento, você não quer que a sessão do atacante sobreviva à troca de senha. Cada um desses detalhes parece pequeno isoladamente, mas juntos eles fecham as brechas por onde a autenticação costuma ser derrubada sem que a criptografia da senha jamais precise ser quebrada.

O que levar para o código#

O fio condutor de tudo isso é uma única postura mental: assuma que o banco vai vazar e projete para que o vazamento não seja o fim. Guarde a senha como hash unidirecional, nunca em texto puro nem cifrado reversível. Use uma KDF lenta e de custo ajustável — argon2id de preferência, ou bcrypt com cost ≥ 12, ou scrypt — nunca um hash rápido como MD5 ou SHA-256. Salt por usuário contra pré-computação, pepper fora do banco contra o dump isolado. Compare em tempo constante. Adote uma política de senha guiada por comprimento e por checagem contra listas vazadas, não por complexidade teatral nem por expiração arbitrária. Exija um segundo fator, prefira passkeys resistentes a phishing e trate SMS como último recurso. Gere todo token com CSPRNG, jamais com Math.random(), e guarde a sessão num cookie HttpOnly + Secure + SameSite. Feche os fluxos com rate limit, respostas genéricas e reset seguro. Nenhuma dessas medidas é cara ou exótica; todas já existem, testadas, nas bibliotecas padrão. O que custa é a atenção — e é exatamente essa atenção que, no dia do dump, decide se você teve um incidente contornável ou uma catástrofe.

Leituras relacionadas

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly