Privacidade Por Equipe DevBras

Capa do artigo: Política de senha NIST: regras obrigatórias e migração

Política de senha NIST: regras obrigatórias e migração

A NIST SP 800-63B-4 vai além do tamanho da senha: lista de bloqueio com o nome do serviço, sem dica, sem truncar, NFC e um plano para largar a troca de 90 dias.

Trocar a senha a cada 90 dias e exigir maiúscula, número e símbolo já era desaconselhado há anos. O que mudou é que a revisão 4 da NIST SP 800-63B, publicada em julho de 2025, transformou boa parte dessas recomendações em obrigação, com a palavra SHALL. E a regra inteira vai além do tamanho da senha: inclui o que fazer com dica de senha, pergunta secreta, botão de colar, limite de tentativas e até o corte silencioso de caracteres que algumas bibliotecas de hash fazem. Este guia percorre a política completa, mostra onde as implementações costumam falhar e termina num plano para migrar um sistema que hoje expira senha por calendário.

Se você procura o código de validação no servidor em PHP, ele está em política de senha no servidor. Aqui o foco é a política em si e a migração.

O que a revisão 4 tornou obrigatório

Do texto da SP 800-63B-4, seção de senhas, estas são as exigências com SHALL ou SHALL NOT:

  • Mínimo de 15 caracteres quando a senha é o único fator. Se ela faz parte de um login com segundo fator, o mínimo pode cair para 8.
  • Nenhuma regra de composição. Exigir mistura de tipos de caractere é proibido.
  • Nenhuma troca periódica. A troca passa a ser obrigatória quando há evidência de que a senha foi comprometida.
  • Comparar com uma lista de bloqueio de senhas comuns, esperadas ou vazadas, e explicar à pessoa por que a senha foi recusada.
  • Contar cada ponto de código Unicode como um caractere ao medir o comprimento.
  • Verificar a senha inteira, sem pedir só algumas posições e sem truncar.
  • Permitir gerenciador de senhas e preenchimento automático.
  • Limitar tentativas falhas por conta.
  • Proibir dica de senha visível para quem não está autenticado e proibir perguntas de segurança ("nome do primeiro animal") na escolha da senha.
  • Guardar com hash de senha, com sal de pelo menos 32 bits.

Como recomendação (SHOULD) ficam: aceitar até pelo menos 64 caracteres, aceitar espaço e qualquer caractere ASCII imprimível, aceitar Unicode, permitir colar no campo, oferecer a opção de mostrar a senha enquanto ela é digitada e normalizar Unicode antes do hash.

As falhas que passam em revisão de código

Quase todo sistema que diz seguir o NIST acerta o comprimento mínimo e erra em algum destes pontos:

  • Pergunta secreta na recuperação de conta. Ela é um segundo segredo, mais fraco que a senha e muitas vezes descobrível nas redes sociais.
  • Campo com onpaste bloqueado ou sem autocomplete="new-password" no cadastro, o que atrapalha o gerenciador.
  • Limite de tentativas só no formulário de login, enquanto o "esqueci a senha" e a API aceitam tentativas sem limite. O post sobre limite no esqueci a senha mostra como limitar esse fluxo por e-mail e por IP.
  • Truncamento escondido no hash. O bcrypt considera só os primeiros 72 bytes, e algumas bibliotecas cortam o resto sem avisar. Com acento, 72 bytes são só 36 letras:
import bcrypt  # pyca/bcrypt 4.2.0
senha = ("á" * 36).encode()          # 72 bytes em UTF-8
h = bcrypt.hashpw(senha + b"X", bcrypt.gensalt())
print(bcrypt.checkpw(senha + b"qualquer coisa", h))
True

A senha cadastrada terminava em X, e o login aceitou outra terminação. Isso fere o "verificar a senha inteira". Se o sistema usa bcrypt, ou limite o tamanho a 72 bytes com mensagem clara, ou use Argon2id, que não tem esse limite. O passo a passo de hash em PHP está em login com senha protegida.

A lista de bloqueio tem duas partes

A parte conhecida é a de senhas vazadas. A API de intervalo do Pwned Passwords recebe só os 5 primeiros caracteres do SHA-1 da senha e devolve os sufixos que começam com eles, então a senha não sai do servidor. Uma consulta feita em 28/10/2026:

import hashlib, urllib.request

def vezes_vazada(senha):
    h = hashlib.sha1(senha.encode()).hexdigest().upper()
    req = urllib.request.Request(
        "https://api.pwnedpasswords.com/range/" + h[:5],
        headers={"User-Agent": "politica-de-senha-teste", "Add-Padding": "true"},
    )
    corpo = urllib.request.urlopen(req, timeout=10).read().decode()
    for linha in corpo.splitlines():
        sufixo, n = linha.split(":")
        if sufixo == h[5:]:
            return int(n)
    return 0

for s in ["Password1!", "Contab@2026!", "Contab@2025"]:
    print(f"{s:14} {vezes_vazada(s)}")
Password1!     584516
Contab@2026!   0
Contab@2025    0

Password1! aparece mais de meio milhão de vezes. Mas as senhas com cara de rodízio trimestral, que usam o nome do próprio serviço, não estão em vazamento nenhum, e são exatamente as que um atacante tenta primeiro contra aquele sistema. Por isso o NIST pede que a lista também tenha palavras específicas do contexto, como o nome do serviço. Ele também avisa que a lista não precisa ser enorme: o objetivo é barrar o que seria adivinhado antes do limite de tentativas entrar em ação.

A segunda parte, então, é gerada a partir do contexto. O código abaixo monta as variações comuns (ano, arroba, exclamação, 123) e compara com a senha inteira, sem procurar pedaços:

// Lista de bloqueio do contexto: palavras do serviço com as variações que as pessoas usam.
// A comparação é com a senha inteira, como pede o NIST, não com pedaços dela.
const ANO = new Date().getFullYear();
const ANOS = [ANO - 2, ANO - 1, ANO, ANO + 1].map(String);
const MEIOS = ['', '@', '#', '_', '.', ...ANOS, ...ANOS.map((a) => '@' + a)];
const FINAIS = ['', '!', '@', '#', '.', '*', '123', '1234', '@123'];

const normalizar = (s) => s.normalize('NFC').toLowerCase();

function montarBloqueio(palavrasDoContexto) {
  const lista = new Set();
  for (const p of palavrasDoContexto) {
    for (const meio of MEIOS) for (const fim of FINAIS) lista.add(normalizar(p + meio + fim));
  }
  return lista;
}

const bloqueio = montarBloqueio(['contab', 'contabilidade', 'portal do cliente']);
for (const senha of ['Contab@2026!', 'Contabilidade2025', 'Portal do Cliente@123', 'conta de luz paga em dia']) {
  const r = bloqueio.has(normalizar(senha)) ? 'bloqueada' : 'segue para a checagem de vazadas';
  console.log(senha.padEnd(26), r);
}
console.log('entradas na lista:', bloqueio.size);
Contab@2026!               bloqueada
Contabilidade2025          bloqueada
Portal do Cliente@123      bloqueada
conta de luz paga em dia   segue para a checagem de vazadas
entradas na lista: 339

Num sistema com segundo fator, onde o mínimo é 8, Contab@2026! passaria no tamanho e só cairia aqui. A frase longa sem relação com o serviço segue para a consulta de vazadas. Quando a senha é recusada, o NIST exige dizer o motivo e orientar a escolha, para a pessoa não tentar Contab@2026!! em seguida. Uma frase como "Use uma frase com palavras que não tenham a ver com o escritório" funciona melhor que "senha inválida". O medidor de força na tela pode dar essa orientação enquanto a pessoa digita.

NFC, não NFKC

Muitos exemplos, inclusive versões antigas deste post, normalizam a senha com NFKC. A SP 800-63B-4 recomenda NFC. A diferença aparece em caracteres de compatibilidade:

import unicodedata as u
for s in ["Senha", "fio dental", "cafe\u0301"]:
    print(repr(s), "NFC:", repr(u.normalize("NFC", s)), "NFKC:", repr(u.normalize("NFKC", s)))
'Senha' NFC: 'Senha' NFKC: 'Senha'
'fio dental' NFC: 'fio dental' NFKC: 'fio dental'
'café' NFC: 'café' NFKC: 'café'

NFKC transforma letras de largura total e ligaduras em letras comuns, o que reduz a senha que a pessoa escolheu. No terceiro caso, a entrada tem 5 pontos de código (um e seguido do acento combinado) e as duas formas a juntam em 4. NFC faz só esse tipo de junção, que é o que resolve o problema real: o mesmo "é" digitado em teclados diferentes. Se o sistema já usa NFKC, não troque de uma vez: as senhas guardadas foram calculadas com a normalização antiga. Passe a usar NFC no próximo cadastro ou troca de cada conta e guarde qual normalização gerou cada hash.

Migrar um sistema que expira senha a cada 90 dias

Desligar a expiração primeiro e cuidar do resto depois deixa o sistema mais fraco por um tempo. A ordem que evita isso:

  1. Ligue o limite de tentativas em todas as portas: login, recuperação de senha e API.
  2. Ligue a lista de bloqueio (vazadas e contexto) no cadastro e na troca.
  3. Confira as senhas atuais no login. No momento do login a senha está em texto claro por um instante. Consulte a lista; se ela estiver nela, deixe entrar e exija a troca logo em seguida.
  4. Ofereça e incentive o segundo fator, começando pelas contas administrativas.
  5. Só então desligue a expiração e defina os gatilhos de troca forçada: senha encontrada em vazamento, login suspeito, conta invadida ou aviso do próprio usuário.
  6. Atualize o texto da tela e a política escrita, para que suporte e auditoria saibam por que a regra mudou.

Se a consulta externa de vazadas falhar, não bloqueie o cadastro por isso: aceite, registre que a checagem não foi feita e repita no próximo login.

Quando a auditoria ainda pede 90 dias

Nem toda norma acompanhou o NIST ao pé da letra. O PCI DSS 4.0.1, padrão de quem processa cartão, no requisito 8.3.9 ainda pede troca a cada 90 dias quando a senha é o único fator de acesso, ou, como alternativa, análise dinâmica da postura de segurança da conta. Com autenticação multifator, essa exigência não se aplica. Os textos oficiais estão na biblioteca de documentos do PCI SSC. Ou seja, num ambiente de cartão, o caminho para abandonar a expiração passa por colocar segundo fator.

Do lado dos provedores, a recomendação da Microsoft para o Microsoft 365 desaconselha expiração para contas somente na nuvem e indica que as senhas não expirem. É um bom argumento para levar a quem ainda acha que trocar por calendário é "mais seguro".

Avalie este artigo

Referência: https://pages.nist.gov/800-63-4/sp800-63b.html