PHP Senhas (password_hash)

A lojinha da Ana vai ter contas de clientes, e conta tem senha. A pergunta desta aula parece simples: como guardar a senha no banco? A resposta curta é: você não guarda a senha. Guarda um "resumo" dela, feito por password_hash, e confere o login com password_verify. São duas funções e poucas linhas, mas entender o porquê evita o erro mais caro da segurança na web.

Você vai aprender
  • Por que senha nunca fica em texto puro no banco, e o que acontece quando o banco vaza.
  • O que é um hash e por que md5 e sha1 não servem para senhas.
  • Criar o hash com password_hash e ler cada parte dele.
  • Conferir a senha no login com password_verify.
  • Atualizar hashes antigos com password_needs_rehash.
  • Um cadastro e login completos com SQLite.

A ideia: a impressão digital

Pense na portaria de um prédio que libera a entrada pela impressão digital. O porteiro não guarda o dedo de ninguém (nem teria como): guarda um registro da digital. Quando você chega, ele lê o seu dedo de novo e compara com o registro. Se alguém roubar o fichário da portaria, leva só desenhos de digitais, e com eles não dá para "fabricar" o seu dedo.

Com senhas é igual. O registro da digital se chama hash: um texto calculado a partir da senha por uma conta que só funciona em um sentido. Da senha se chega ao hash; do hash não se volta à senha. No login, o PHP calcula de novo a partir do que a pessoa digitou e compara.

Por que não guardar a senha como ela é?

Porque bancos de dados vazam: um backup esquecido num servidor, uma falha de SQL injection, um funcionário mal-intencionado, um notebook roubado. Se a tabela tiver as senhas em texto puro, quem pegar o arquivo tem acesso imediato a todas as contas. E pior: como muita gente usa a mesma senha em vários lugares, o vazamento da lojinha vira acesso ao e-mail e ao banco dos clientes.

A regra profissional é: nem você, dona do sistema, deve conseguir ler a senha dos seus usuários. Se alguém pedir "me manda minha senha por e-mail", a resposta certa é "não tenho a sua senha; clique aqui para criar uma nova".


Por que não md5 nem sha1

O PHP tem funções de hash antigas, como md5 e sha1. Muitos tutoriais antigos usam md5($senha). Veja o problema:

<?php
echo md5('123456'), "\n";
echo sha1('123456'), "\n";
echo md5('girassol2026'), "\n";
echo md5('girassol2026'), "\n";
$ php md5.php
e10adc3949ba59abbe56e057f20f883e
7c4a8d09ca3762af61e59520943dc26494f8941b
3c4d43d1b9ed88b555a6b88a0eec1cf2
3c4d43d1b9ed88b555a6b88a0eec1cf2

Três problemas aparecem aqui:

  1. A mesma senha dá sempre o mesmo hash. As duas últimas linhas são iguais. Se a Ana e a Bia usarem a mesma senha, o banco mostra isso. E existem tabelas prontas, com bilhões de senhas comuns e seus md5: cole e10adc3949ba59abbe56e057f20f883e num buscador e ele responde 123456 na hora.
  2. É rápido demais. O md5 foi feito para conferir arquivos grandes, então foi pensado para ser veloz. Para senhas isso é ruim: quem roubou o banco consegue testar bilhões de senhas por segundo numa placa de vídeo.
  3. Não tem como ficar mais forte com o tempo. O cálculo é fixo; computadores ficam mais rápidos e o md5 continua igual.

Para sentir a diferença de velocidade, a Ana mediu no notebook dela (números pequenos, só para comparar):

<?php
$inicio = hrtime(true);
for ($i = 0; $i < 100000; $i++) {
    md5("senha$i");
}
$ms = (hrtime(true) - $inicio) / 1e6;
printf("md5: 100000 senhas em %.0f ms\n", $ms);

$inicio = hrtime(true);
for ($i = 0; $i < 10; $i++) {
    password_hash("senha$i", PASSWORD_DEFAULT);
}
$ms = (hrtime(true) - $inicio) / 1e6;
printf("password_hash: 10 senhas em %.0f ms\n", $ms);
$ php tempo.php
md5: 100000 senhas em 37 ms
password_hash: 10 senhas em 2675 ms

Cem mil md5 em 37 milissegundos, contra dez password_hash em 2,7 segundos: cada hash de senha levou cerca de um quarto de segundo. Para a Ana, que faz login uma vez, um quarto de segundo é imperceptível. Para um atacante tentando bilhões de senhas, é a diferença entre minutos e séculos. A lentidão é de propósito. (Os números mudam de computador para computador; a proporção é o que importa.)

md5, sha1, sha256, hash('sha512', …), "md5 duas vezes", "md5 com um texto fixo grudado": nenhum serve para senhas. Todos são rápidos. Para senha, só password_hash.

Criar o hash: password_hash

<?php
$senha = 'girassol2026';

$hash = password_hash($senha, PASSWORD_DEFAULT);

echo $hash, "\n";
echo strlen($hash), " caracteres\n";
password_hash($senha, …)
"Hash de senha": calcula o hash, com um sal aleatório e um algoritmo lento, e devolve tudo num texto só.
PASSWORD_DEFAULT
"O algoritmo recomendado pelo PHP nesta versão". Hoje é o bcrypt. Se um dia o PHP mudar o padrão, o seu código acompanha sem alteração.
$ php hash.php
$2y$12$Pap3KLoGixAspX8P4ZhtcejkgCOi4nFnqlEoqTUhgVn8q11r6AsNC
60 caracteres

Esse texto é o que vai para o banco. Ele parece aleatório, mas tem partes bem definidas:

ParteValorSignifica
Algoritmo$2y$bcrypt.
Custo12$Quanto trabalho o cálculo dá: 212 = 4.096 rodadas. Cada +1 dobra o tempo. O padrão subiu de 10 para 12 no PHP 8.4.
Sal (salt)Pap3KLoGixAspX8P4Zhtce22 caracteres sorteados na hora. Faz a mesma senha gerar hashes diferentes.
HashjkgCOi4nFnqlEoqTUhgVn8q11r6AsNC31 caracteres: o resultado do cálculo com a senha, o sal e o custo.

O sal é o que resolve o problema das tabelas prontas. Rode duas vezes com a mesma senha:

<?php
echo password_hash('girassol2026', PASSWORD_DEFAULT), "\n";
echo password_hash('girassol2026', PASSWORD_DEFAULT), "\n";
$ php dois.php
$2y$12$E1RoKxAq18QQFD4t8OLKYel0gZVRWoShRsbl9DRwV23WEXVkaKDN6
$2y$12$OiKObogMlwBPzTvTFW7AY.KmF7fyg6p8Leqz37K2u0S57gPJ2H4fq

Mesma senha, hashes completamente diferentes (e diferentes do hash.php). Quando você rodar, vai ver outros ainda: o sal é sorteado a cada chamada. Não existe tabela pronta que sirva, e duas pessoas com a mesma senha não ficam visíveis no banco.

Onde está o sal? Dentro do próprio hash (a parte depois do custo). Você não precisa guardá-lo numa coluna separada nem inventar o seu: o password_hash sorteia, e o password_verify lê de volta. Na tabela, uma coluna VARCHAR(255) basta para o hash inteiro (o bcrypt usa 60 caracteres, mas outros algoritmos são maiores).

Conferir no login: password_verify

No login, a pessoa digita a senha e você tem o hash do banco. Não dá para chamar password_hash de novo e comparar (o sal seria outro). Quem faz a conferência é o password_verify: ele lê o algoritmo, o custo e o sal de dentro do hash, refaz o cálculo com a senha digitada e compara.

<?php
$hash = '$2y$12$Pap3KLoGixAspX8P4ZhtcejkgCOi4nFnqlEoqTUhgVn8q11r6AsNC';

var_dump(password_verify('girassol2026', $hash));
var_dump(password_verify('Girassol2026', $hash));
var_dump(password_verify('girassol', $hash));
'$2y$12$…'
O hash gerado no hash.php, como se viesse do banco. Aspas simples: com aspas duplas, o PHP tentaria ler $2y como variável.
password_verify($senha, $hash)
"Verificar senha": true se a senha corresponde ao hash. A ordem é sempre senha primeiro, hash depois.
$ php verificar.php
bool(true)
bool(false)
bool(false)

Só a senha exata passa. Uma letra maiúscula diferente já basta para recusar.

Experimente: rode e compare os dois hashes (diferentes, mesmo com a mesma senha). Depois troque o texto de $digitada por outras tentativas e veja o password_verify mudar. Cada password_hash leva uma fração de segundo de propósito: no editor do guia, que roda o PHP dentro do navegador, pode demorar um pouco mais.

<?php
$senha = 'girassol2026';

$hash1 = password_hash($senha, PASSWORD_DEFAULT);
$hash2 = password_hash($senha, PASSWORD_DEFAULT);

echo "Hash 1: $hash1\n";
echo "Hash 2: $hash2\n";

$digitada = 'girassol2026';

echo "Confere com o hash 1? ";
var_dump(password_verify($digitada, $hash1));
echo "Confere com o hash 2? ";
var_dump(password_verify($digitada, $hash2));

Ficar mais forte com o tempo: password_needs_rehash

Os computadores ficam mais rápidos, e o custo que era bom ontem fica fraco amanhã. Foi o que aconteceu no PHP 8.4, quando o custo padrão subiu de 10 para 12. Mas e os hashes que já estão no banco, feitos com custo 10? Você não tem as senhas para refazê-los… exceto no momento do login, quando a pessoa digita a senha e ela acabou de ser conferida.

O password_needs_rehash ("precisa refazer o hash?") responde se um hash foi feito com configurações diferentes das atuais:

<?php
$antigo = password_hash('girassol2026', PASSWORD_DEFAULT, ['cost' => 10]);
$atual = password_hash('girassol2026', PASSWORD_DEFAULT);

echo $antigo, "\n";
echo $atual, "\n";

var_dump(password_needs_rehash($antigo, PASSWORD_DEFAULT));
var_dump(password_needs_rehash($atual, PASSWORD_DEFAULT));
var_dump(password_needs_rehash(md5('girassol2026'), PASSWORD_DEFAULT));
$ php rehash.php
$2y$10$9f9HyAqNZiTQNAVeT87en.DlxVBpd96Is5hT6TSHYbIzjTIPD2SXm
$2y$12$0XOpUr.YtpP32bdPx7/e5OEQXo8o//FleRCnRDjovIb4RW5fT5o7y
bool(true)
bool(false)
bool(true)
['cost' => 10]
O 3º argumento do password_hash aceita opções. Aqui, forçamos o custo antigo para simular um hash de 2023. Repare no $2y$10$.
password_needs_rehash($hash, PASSWORD_DEFAULT)
true se o hash não foi feito com o algoritmo e as opções atuais. O hash antigo (custo 10) precisa; o atual (custo 12) não; e um md5 também "precisa", porque nem é bcrypt.

O uso é sempre o mesmo, logo depois de um password_verify bem-sucedido: se precisa, calcule um hash novo com a senha que a pessoa acabou de digitar e salve no banco. Assim os usuários vão sendo atualizados aos poucos, sem perceber.


Cadastro e login completos

Juntando tudo com um banco SQLite em memória (e prepared statements, claro):

<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE usuarios (email TEXT PRIMARY KEY, senha_hash TEXT NOT NULL)');

function cadastrar(PDO $pdo, string $email, string $senha): void
{
    $hash = password_hash($senha, PASSWORD_DEFAULT);
    $consulta = $pdo->prepare('INSERT INTO usuarios (email, senha_hash) VALUES (?, ?)');
    $consulta->execute([$email, $hash]);
}

function entrar(PDO $pdo, string $email, string $senha): bool
{
    $consulta = $pdo->prepare('SELECT senha_hash FROM usuarios WHERE email = ?');
    $consulta->execute([$email]);
    $hash = $consulta->fetchColumn();

    if ($hash === false || !password_verify($senha, $hash)) {
        return false;
    }

    if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
        $novoHash = password_hash($senha, PASSWORD_DEFAULT);
        $pdo->prepare('UPDATE usuarios SET senha_hash = ? WHERE email = ?')
            ->execute([$novoHash, $email]);
        echo "(hash de $email atualizado)\n";
    }

    return true;
}

cadastrar($pdo, 'ana@lojinha.com', 'girassol2026');

// Um usuário antigo, cadastrado quando o custo era 10
$antigo = password_hash('bia1234', PASSWORD_DEFAULT, ['cost' => 10]);
$pdo->prepare('INSERT INTO usuarios VALUES (?, ?)')->execute(['bia@lojinha.com', $antigo]);

foreach ([
    ['ana@lojinha.com', 'girassol2026'],
    ['ana@lojinha.com', 'Girassol2026'],
    ['caio@lojinha.com', 'girassol2026'],
    ['bia@lojinha.com', 'bia1234'],
] as [$email, $senha]) {
    $resultado = entrar($pdo, $email, $senha) ? 'entrou' : 'E-mail ou senha incorretos';
    echo "$email / $senha: $resultado\n";
}

foreach ($pdo->query('SELECT email, senha_hash FROM usuarios') as $linha) {
    echo $linha['email'], ' → ', $linha['senha_hash'], "\n";
}
$ php login.php
ana@lojinha.com / girassol2026: entrou
ana@lojinha.com / Girassol2026: E-mail ou senha incorretos
caio@lojinha.com / girassol2026: E-mail ou senha incorretos
(hash de bia@lojinha.com atualizado)
bia@lojinha.com / bia1234: entrou
ana@lojinha.com → $2y$12$86DB4qzaZ/VZLuReHjhTDeMADLTSygZxKXcNNOB5H9U5higEQi3N.
bia@lojinha.com → $2y$12$6mByblFtRKoG9.U/F3H4Pe4/v/mFQPFqIkIAjT5vilXzByZ83zHhe
TrechoPor quê
senha_hash TEXTA coluna guarda o hash, nunca a senha. O nome deixa isso claro para quem ler o banco.
$hash === falsefetchColumn devolve false quando o e-mail não existe (o caio).
Mesma mensagem para e-mail inexistente e senha errada"E-mail ou senha incorretos". Se você disser "e-mail não cadastrado", um atacante descobre quais e-mails têm conta na lojinha.
password_needs_rehash depois do verifyA Bia tinha um hash de custo 10. No login, ele foi refeito com custo 12: veja o $2y$12$ na última linha.

O projeto Login com Sessão usa estas funções numa página web de verdade, com sessões e proteção CSRF (a próxima aula).


Formas diferentes

ChamadaResultadoQuando usar
password_hash($s, PASSWORD_DEFAULT)$2y$12$… (bcrypt)Sempre, a não ser que você tenha um motivo.
password_hash($s, PASSWORD_BCRYPT, ['cost' => 13])$2y$13$4docEuQECsa4fLPoFYivcezHBTorROJq5SANS9jlBqPGJi5P.AKQSPara escolher o custo. Meça no seu servidor: o ideal é cada hash levar entre 0,1 e 0,5 segundo.
password_hash($s, PASSWORD_ARGON2ID)$argon2id$v=19$m=65536,t=4,p=1$IC5hvs6DZQpB66py7WZWxA$w8wx7NvNv9Ep2/URGqePGmsKQ1hemG2oEzSgxrh5pRAArgon2id: mais moderno, também gasta memória (dificulta ataques com placas de vídeo). Só existe se o PHP foi compilado com ele (defined('PASSWORD_ARGON2ID')).
md5, sha1, hash(), crypt() direto—Nunca para senhas.

Todos os hashes acima são conferidos pelo mesmo password_verify: ele reconhece o algoritmo pelo começo do texto. Se o banco tiver uma mistura de bcrypt e Argon2id, tudo continua funcionando.


Quando dá errado

password_verify sempre devolve false, até com a senha certa
<?php
$hash = '$2y$12$Pap3KLoGixAspX8P4ZhtcejkgCOi4nFnqlEoqTUhgVn8q11r6AsNC';

var_dump(password_verify($hash, 'girassol2026'));
var_dump(password_verify('girassol2026', substr($hash, 0, 50)));
$ php erros.php
bool(false)
bool(false)

O que aconteceu: duas causas clássicas. Na primeira linha, os argumentos estão trocados (o certo é password_verify($senha, $hash)). Na segunda, o hash está cortado: é o que acontece quando a coluna do banco é pequena demais (VARCHAR(50)) e o MySQL corta o texto ao salvar. Nenhuma das duas dá erro: só false.

Como consertar: confira a ordem dos argumentos; use VARCHAR(255) na coluna; e confira se o hash no banco tem 60 caracteres e começa com $2y$. Outra causa comum: o hash foi escrito entre aspas duplas no código, e o PHP trocou $2y e $12 por variáveis (vazias).

Comparar com password_hash em vez de verificar
if (password_hash($digitada, PASSWORD_DEFAULT) === $hashSalvo) {

O que aconteceu: nunca é verdadeiro. Cada password_hash sorteia um sal novo, então o hash calculado agora nunca é igual ao salvo. É a missão do fim da aula.

Como consertar: if (password_verify($digitada, $hashSalvo)) {.

"Bcrypt password must not contain null character"
$ php -r 'password_hash("abc\0def", PASSWORD_DEFAULT);'

Fatal error: Uncaught ValueError: Bcrypt password must not contain null character in Command line code:1
Stack trace:
#0 Command line code(1): password_hash(Object(SensitiveParameterValue), '2y')
#1 {main}
  thrown in Command line code on line 1

O que aconteceu: a senha tinha um caractere nulo (\0), que o bcrypt não aceita: tudo depois dele seria ignorado. O PHP lança um ValueError em vez de criar um hash enganoso. Repare que, no rastro do erro, a senha aparece como Object(SensitiveParameterValue): o PHP esconde de propósito os argumentos marcados como sensíveis, para a senha não ir parar em logs. Na prática, só acontece se alguém enviar dados estranhos de propósito.

Como consertar: valide a senha antes (tamanho mínimo e máximo, sem caracteres de controle) e trate o erro com uma mensagem amigável.

Senhas muito longas: o limite de 72 bytes do bcrypt
<?php
$senha = str_repeat('a', 72);
$hash = password_hash($senha, PASSWORD_DEFAULT);

var_dump(password_verify($senha . 'qualquer coisa', $hash));
$ php limite.php
bool(true)

O que aconteceu: o bcrypt só usa os primeiros 72 bytes da senha. Tudo depois disso é ignorado, então uma senha de 72 letras "a" seguida de qualquer coisa confere com o mesmo hash. (Acentos ocupam 2 bytes cada em UTF-8.)

Como consertar: limite o tamanho da senha no cadastro (por exemplo, até 64 caracteres), ou use PASSWORD_ARGON2ID, que não tem esse limite. Na prática, senhas de mais de 72 bytes são raras.


Referência rápida

FunçãoDevolvePara quê
password_hash($senha, PASSWORD_DEFAULT)string (o hash)Cadastro e troca de senha
password_verify($senha, $hash)boolLogin
password_needs_rehash($hash, PASSWORD_DEFAULT)boolDepois de um login certo: refazer hashes antigos
password_get_info($hash)arrayVer o algoritmo e as opções de um hash (algoName = bcrypt, cost = 12)
PASSWORD_DEFAULT / PASSWORD_BCRYPT / PASSWORD_ARGON2ID—Algoritmo (use o padrão)

Exercícios

1. Guardar a senha

Complete o cadastro:

$hash = ;

2. Conferir no login

Complete com a função que confere a senha digitada contra o hash do banco:

if (($digitada, $hashDoBanco)) {

3. O tamanho da coluna

Complete a coluna da tabela com o tamanho recomendado para guardar hashes:

senha_hash VARCHAR() NOT NULL

4. Por que md5($senha) não serve para guardar senhas?

5. A Ana rodou password_hash('abc', PASSWORD_DEFAULT) duas vezes e recebeu dois textos diferentes. O que isso significa?

6. Em que momento chamar password_needs_rehash?

Missão final: a Ana digitou a senha certa, mas este login responde "Senha incorreta". O programador tentou conferir gerando um hash novo e comparando com o salvo. Conserte a condição do if para usar a função certa e fazer aparecer Bem-vinda, Ana!. Depois, troque $tentativa por uma senha errada e confira que ela é recusada.

<?php
// O hash que está salvo no banco para a Ana
$hashSalvo = '$2y$12$Pap3KLoGixAspX8P4ZhtcejkgCOi4nFnqlEoqTUhgVn8q11r6AsNC';

// O que ela digitou no formulário de login
$tentativa = 'girassol2026';

if (password_hash($tentativa, PASSWORD_DEFAULT) === $hashSalvo) {
    echo "Bem-vinda, Ana!\n";
} else {
    echo "Senha incorreta\n";
}
<?php
// O hash que está salvo no banco para a Ana
$hashSalvo = '$2y$12$Pap3KLoGixAspX8P4ZhtcejkgCOi4nFnqlEoqTUhgVn8q11r6AsNC';

// O que ela digitou no formulário de login
$tentativa = 'girassol2026';

if (password_verify($tentativa, $hashSalvo)) {
    echo "Bem-vinda, Ana!\n";
} else {
    echo "Senha incorreta\n";
}

Resumo

  • Nunca guarde a senha, nem criptografada de um jeito que dê para "abrir": guarde o hash de password_hash($senha, PASSWORD_DEFAULT).
  • O hash ($2y$12$…, 60 caracteres) traz junto o algoritmo, o custo e um sal aleatório: a mesma senha gera hashes diferentes a cada vez.
  • No login, password_verify($senha, $hash). Nunca compare com um password_hash novo.
  • Depois de um login certo, password_needs_rehash diz se vale refazer o hash com as configurações atuais.
  • md5, sha1 e companhia são rápidos e sem sal: não servem para senha.
  • Coluna VARCHAR(255), hash entre aspas simples no código, e a mesma mensagem para "e-mail não existe" e "senha errada".