Vérifier qu’un mot de passe respecte la politique
Décider si un mot de passe choisi à l’inscription peut être accepté, sans le faire sortir de votre infrastructure.
Pas besoin d’IA
GratuitRapideVos données partent chez un tiers
Le verdict
N0, parce que la norme énonce des règles et non des jugements, et parce que la seule exigence qui demande une donnée extérieure se satisfait sans envoyer la donnée : cinq caractères du SHA-1 partent, le service rend huit cents suffixes, et la comparaison se fait chez vous. Le test de la fiche lit l’adresse demandée et vérifie que ni le mot de passe, ni son empreinte complète, ni même son suffixe ne s’y trouvent. Le reste du code est surtout fait de ce qu’il ne fait pas : aucune règle de composition, parce que la norme les interdit et parce qu’elles produisent « Motdepasse1! », que la liste de fuites attrape et qu’aucune règle de composition n’attrape.
Les règles de la SP 800-63B, et la liste de fuites en k-anonymat
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitRapide
Python
"""
Contrôler un mot de passe contre la politique que la norme demande vraiment.
Niveau N0. La NIST SP 800-63B-4 est courte, et ses exigences sont des règles,
pas des jugements : quinze caractères pour un mot de passe employé seul, au
moins soixante-quatre permis, aucune règle de composition, aucun changement
périodique, et une comparaison à une liste de mots de passe déjà connus pour
avoir fuité. Quatre des cinq sont de l'arithmétique sur une chaîne.
Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
jugement.
Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
jugement.
La cinquième est la seule qui sorte de la machine, et elle en sort presque
rien. Le service Have I Been Pwned prend les cinq premiers caractères du
SHA-1 du mot de passe et rend tous les suffixes qui commencent par eux — huit
cents environ — si bien que le mot de passe n'est jamais envoyé, que le
service ne peut pas savoir lequel des huit cents était demandé, et que la
réponse est un compte. C'est ce que fait `pwned_count` plus bas, et cela mérite
d'être lu : tout l'argument de confidentialité tient en deux lignes.
Quelque chose sort donc de votre infrastructure — cinq caractères d'une
empreinte, jamais le mot de passe —, et le bloc de risques de cette fiche le
déclare : `third-party`. Et parce que ce contrôle tourne sur un chemin
d'inscription, un service qui ne répond pas ne lève pas ici : l'échec revient
en `blocklist-unavailable` dans `reasons`, avec `breaches` nul, et c'est
l'appelant qui décide d'accepter ou de refuser l'inscription. Refuser tout le
monde parce qu'un tiers est en panne est une panne qu'on n'a pas choisie ;
accepter en silence, c'est sauter sans le dire le seul contrôle que la norme
met en SHALL.
Ce que ce fichier ne fait délibérément pas, c'est compter les majuscules, les
chiffres et les symboles. La norme l'interdit — « Verifiers and CSPs SHALL
NOT impose other composition rules » — parce que ces règles poussent vers
« Motdepasse1! », que la liste de fuites attrape et qu'aucune règle de
composition n'attrape.
"""
from __future__ import annotations
import hashlib
import unicodedata
# SP 800-63B-4, section 3.1.1.2: fifteen characters for a password used as a
# single factor, eight when it is only one factor among several.
MINIMUM = 15
MINIMUM_WITH_SECOND_FACTOR = 8
# The standard says « SHOULD permit a maximum password length of at least 64
# characters »: sixty-four is a floor on the ceiling, not the ceiling. Taking
# it as one turns down a seventy-character passphrase — six words out of a
# password manager — which is a good password refused by a rule of shape, in
# an entry whose whole thesis is that rules of shape push people towards bad
# ones. Two hundred and fifty-six is the usual ceiling; it exists to bound the
# hashing, not to judge anything.
MAXIMUM = 256
RANGE_URL = "https://api.pwnedpasswords.com/range/"
# Reasons that block. A blocklist that did not answer is reported and never
# decided: refusing every sign-up because a third party is down is an outage
# you did not choose, and accepting in silence is the check the standard puts
# in a SHALL, skipped without a word. The caller decides; `reasons` is what it
# decides on.
BLOCKING = ("not-text", "too-short", "too-long", "context-word", "breached")
def pwned_count(password: str, fetch=None) -> int:
"""
How many breaches this password appears in, without sending the password.
Only the first five characters of its SHA-1 leave: the service answers with
every suffix sharing that prefix, and the comparison happens here. The
`Add-Padding` header makes every answer the same size, so the length of the
response says nothing either.
"""
if fetch is None: # pragma: no cover - needs a network
def fetch(url: str) -> str:
import urllib.request
request = urllib.request.Request(url, headers={"Add-Padding": "true"})
with urllib.request.urlopen(request, timeout=5) as response:
return response.read().decode("utf-8")
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
for line in fetch(RANGE_URL + digest[:5]).splitlines():
suffix, _, count = line.strip().partition(":")
if suffix == digest[5:]:
return int(count)
return 0
def check_password(password, *, second_factor: bool = False, context=(), fetch=None) -> dict:
"""
Say whether this password may be accepted, and why not when it may not.
`context` is the short list of words a password must not be — the site's
name, the account's local part — which the standard allows a blocklist to
carry. It is compared after normalisation and without case, and nothing
else about the password's shape is judged.
`fetch` is injected by the tests; in production it is an HTTPS request.
It runs on a sign-up path, so a service that does not answer must not raise
here: the failure comes back as `blocklist-unavailable` in `reasons`, with
`breaches` at None, and `acceptable` says what the rest of the check says.
Only `BLOCKING` reasons make a password unacceptable.
"""
if not isinstance(password, str):
return _report(False, ["not-text"], None, 0)
# The standard asks for NFC, so that a password typed with a composed « é »
# and one typed with « e » plus an accent are the same password.
normalised = unicodedata.normalize("NFC", password)
length = len(normalised)
reasons = []
floor = MINIMUM_WITH_SECOND_FACTOR if second_factor else MINIMUM
if length < floor:
reasons.append("too-short")
if length > MAXIMUM:
reasons.append("too-long")
# Lower case rather than case folding, so that the two languages of this
# page compare the same way: folding turns « ß » into « ss », lowering
# leaves it alone, and JavaScript has no folding.
folded = normalised.lower()
if any(folded == unicodedata.normalize("NFC", word).lower() for word in context):
reasons.append("context-word")
# The blocklist is asked last, and only for a password that could still be
# accepted: a request that will change nothing is a request not to make.
breaches = None
if not reasons:
try:
breaches = pwned_count(normalised, fetch)
except Exception: # noqa: BLE001 - see BLOCKING: an outage is not a verdict
reasons.append("blocklist-unavailable")
else:
if breaches:
reasons.append("breached")
blocking = [reason for reason in reasons if reason in BLOCKING]
return _report(not blocking, reasons, breaches, length)
def _report(acceptable: bool, reasons: list, breaches: int | None, length: int) -> dict:
return {"acceptable": acceptable, "reasons": reasons, "breaches": breaches, "length": length}JavaScript
/**
* Contrôler un mot de passe contre la politique que la norme demande vraiment.
*
* Niveau N0. La NIST SP 800-63B-4 est courte, et ses exigences sont des règles,
* pas des jugements : quinze caractères pour un mot de passe employé seul, au
* moins soixante-quatre permis, aucune règle de composition, aucun changement
* périodique, et une comparaison à une liste de mots de passe déjà connus pour
* avoir fuité. Quatre des cinq sont de l'arithmétique sur une chaîne.
*
* Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
* plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
* dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
* dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
* toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
* défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
* jugement.
*
* Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
* plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
* dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
* dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
* toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
* défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
* jugement.
*
* La cinquième est la seule qui sorte de la machine, et elle en sort presque
* rien. Le service Have I Been Pwned prend les cinq premiers caractères du
* SHA-1 du mot de passe et rend tous les suffixes qui commencent par eux — huit
* cents environ — si bien que le mot de passe n'est jamais envoyé, que le
* service ne peut pas savoir lequel des huit cents était demandé, et que la
* réponse est un compte. C'est ce que fait `pwnedCount` plus bas, et cela mérite
* d'être lu : tout l'argument de confidentialité tient en deux lignes.
*
* Quelque chose sort donc de votre infrastructure — cinq caractères d'une
* empreinte, jamais le mot de passe —, et le bloc de risques de cette fiche le
* déclare : `third-party`. Et parce que ce contrôle tourne sur un chemin
* d'inscription, un service qui ne répond pas ne lève pas ici : l'échec revient
* en `blocklist-unavailable` dans `reasons`, avec `breaches` nul, et c'est
* l'appelant qui décide d'accepter ou de refuser l'inscription. Refuser tout le
* monde parce qu'un tiers est en panne est une panne qu'on n'a pas choisie ;
* accepter en silence, c'est sauter sans le dire le seul contrôle que la norme
* met en SHALL.
*
* Ce que ce fichier ne fait délibérément pas, c'est compter les majuscules, les
* chiffres et les symboles. La norme l'interdit — « Verifiers and CSPs SHALL
* NOT impose other composition rules » — parce que ces règles poussent vers
* « Motdepasse1! », que la liste de fuites attrape et qu'aucune règle de
* composition n'attrape.
*/
import { createHash } from 'node:crypto';
// SP 800-63B-4, section 3.1.1.2: fifteen characters for a password used as a
// single factor, eight when it is only one factor among several.
export const MINIMUM = 15;
export const MINIMUM_WITH_SECOND_FACTOR = 8;
// The standard says « SHOULD permit a maximum password length of at least 64
// characters »: sixty-four is a floor on the ceiling, not the ceiling. Taking
// it as one turns down a seventy-character passphrase — six words out of a
// password manager — which is a good password refused by a rule of shape, in
// an entry whose whole thesis is that rules of shape push people towards bad
// ones. Two hundred and fifty-six is the usual ceiling; it exists to bound the
// hashing, not to judge anything.
export const MAXIMUM = 256;
export const RANGE_URL = 'https://api.pwnedpasswords.com/range/';
// Reasons that block. A blocklist that did not answer is reported and never
// decided: refusing every sign-up because a third party is down is an outage
// you did not choose, and accepting in silence is the check the standard puts
// in a SHALL, skipped without a word. The caller decides; `reasons` is what it
// decides on.
export const BLOCKING = ['not-text', 'too-short', 'too-long', 'context-word', 'breached'];
/**
* How many breaches this password appears in, without sending the password.
*
* Only the first five characters of its SHA-1 leave: the service answers with
* every suffix sharing that prefix, and the comparison happens here. The
* `Add-Padding` header makes every answer the same size, so the length of the
* response says nothing either.
*/
export async function pwnedCount(password, fetchRange) {
const get = fetchRange ?? (async (url) => {
/* c8 ignore next 3 -- needs a network */
const response = await fetch(url, { headers: { 'Add-Padding': 'true' } });
return response.text();
});
const digest = createHash('sha1').update(password, 'utf8').digest('hex').toUpperCase();
const body = await get(RANGE_URL + digest.slice(0, 5));
for (const line of body.split(/\r?\n/)) {
const [suffix, count] = line.trim().split(':');
if (suffix === digest.slice(5)) return Number(count);
}
return 0;
}
/**
* Say whether this password may be accepted, and why not when it may not.
*
* `context` is the short list of words a password must not be — the site's
* name, the account's local part — which the standard allows a blocklist to
* carry. It is compared after normalisation and without case, and nothing else
* about the password's shape is judged.
*
* `fetchRange` is injected by the tests; in production it is an HTTPS request.
* It runs on a sign-up path, so a service that does not answer must not throw
* here: the failure comes back as `blocklist-unavailable` in `reasons`, with
* `breaches` at null, and `acceptable` says what the rest of the check says.
* Only `BLOCKING` reasons make a password unacceptable.
*
* @param {string} password
* @param {{secondFactor?: boolean, context?: string[], fetchRange?: Function}} [options]
*/
export async function checkPassword(password, { secondFactor = false, context = [], fetchRange } = {}) {
if (typeof password !== 'string') return report(false, ['not-text'], null, 0);
// The standard asks for NFC, so that a password typed with a composed « é »
// and one typed with « e » plus an accent are the same password.
const normalised = password.normalize('NFC');
const length = [...normalised].length;
const reasons = [];
const floor = secondFactor ? MINIMUM_WITH_SECOND_FACTOR : MINIMUM;
if (length < floor) reasons.push('too-short');
if (length > MAXIMUM) reasons.push('too-long');
// Lower case rather than case folding, so that the two languages of this
// page compare the same way: folding turns « ß » into « ss », lowering
// leaves it alone, and JavaScript has no folding.
const folded = normalised.toLowerCase();
if (context.some((word) => word.normalize('NFC').toLowerCase() === folded)) {
reasons.push('context-word');
}
// The blocklist is asked last, and only for a password that could still be
// accepted: a request that will change nothing is a request not to make.
let breaches = null;
if (reasons.length === 0) {
try {
breaches = await pwnedCount(normalised, fetchRange);
if (breaches) reasons.push('breached');
} catch {
// See BLOCKING: an outage is not a verdict.
reasons.push('blocklist-unavailable');
}
}
const blocking = reasons.filter((reason) => BLOCKING.includes(reason));
return report(blocking.length === 0, reasons, breaches, length);
}
const report = (acceptable, reasons, breaches, length) => ({
acceptable, reasons, breaches, length,
});Essayer
Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.
La liste de fuites est un double local, et les comptes affichés sont les siens. Ce qui compte ici est ailleurs : l’adresse demandée, montrée sous chaque cas, ne contient que cinq caractères du haché.
Une phrase de passe française
Donné
le chat dort sur le radiateur
Ce qu’il renvoie
{ "acceptable": true, "reasons": [], "breaches": 0, "length": 29 }
Ce qu’il en conclut
Accepté
Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/409F0
Le mot de passe que produisent les règles de composition
Donné
Motdepasse1!
Ce qu’il renvoie
{ "acceptable": false, "reasons": [ "too-short" ], "breaches": null, "length": 12 }
Ce qu’il en conclut
Refusé — trop court
Rien n’est sorti de la machine : le refus s’est joué avant la liste.
Seize lettres identiques, sans majuscule ni chiffre
Donné
aaaaaaaaaaaaaaaa
Ce qu’il renvoie
{ "acceptable": false, "reasons": [ "breached" ], "breaches": 3, "length": 16 }
Ce qu’il en conclut
Refusé — présent dans une fuite
Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/3499C
Une phrase de passe célèbre
Donné
correct horse battery staple
Ce qu’il renvoie
{ "acceptable": false, "reasons": [ "breached" ], "breaches": 120, "length": 28 }
Ce qu’il en conclut
Refusé — présent dans une fuite
Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/ABF7A
Le nom du site lui-même
Donné
Boulangerie-Martin
Ce qu’il renvoie
{ "acceptable": false, "reasons": [ "context-word" ], "breaches": null, "length": 18 }
Ce qu’il en conclut
Refusé — c’est le nom du site
Rien n’est sorti de la machine : le refus s’est joué avant la liste.
Le prénom et l’année de naissance de la fille du titulaire
Donné
Clementine-2019-Martin
Ce qu’il renvoie
{ "acceptable": true, "reasons": [], "breaches": 0, "length": 22 }
Ce qu’il en conclut
Accepté
Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/E6EA2
Là, ça casseVingt-deux caractères, aucune fuite connue : la politique l’accepte, et c’est le prénom de la fille du titulaire suivi de son année de naissance. Le contrôle ne connaît que ce qui a déjà fuité ; ce qu’un proche devinerait en trois essais ne laisse aucune trace dans une liste. C’est la limite du niveau, et elle ne se referme pas avec un modèle : elle se referme avec un second facteur.
Ce que ça implique
- Vos données partent chez un tiers
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Dépend d’un fournisseur externe
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Le mot de passe ne sort pas : seuls cinq caractères de son empreinte SHA-1 sont transmis, et le test de la fiche vérifie ce qui part
- Dépendance à un service tiers pour la liste de fuites : sa disponibilité conditionne ce contrôle, et l’appelant doit décider quoi faire quand il ne répond pas
Point de rupture
Le contrôle ne connaît que ce qui a déjà fuité : « Clementine-2019-Martin » — vingt-deux caractères, le prénom et l’année de naissance de la fille du titulaire — n’est dans aucune liste, et passe. Le témoin est dans le même test : « correct horse battery staple », assez long lui aussi, est refusé parce qu’il a fuité.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus, et c’est la conclusion de la norme elle-même : ce qu’un proche devinerait en trois essais ne laisse aucune trace dans une liste de fuites. La réponse est un second facteur, pas une règle de plus sur le mot de passe — et `context` ne compare que des mots entiers, pas des morceaux : « Boulangerie-Martin-2026 » passe une liste qui porte « Boulangerie-Martin ».
Les quatre options
- RecommandéN0Les règles de la SP 800-63B, et la liste de fuites en k-anonymatGratuit · Rapide
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
L’outil classique existe et s’appelle zxcvbn : il estime le nombre d’essais nécessaires en cherchant des motifs — mots du dictionnaire, dates, suites de clavier — et c’est un indicateur utile à afficher pendant la saisie. Il n’est pas mis dans l’échelle ici parce qu’il ne répond pas à la même question : la norme demande une décision, pas une note, et ses dictionnaires sont ceux de l’anglais dans le portage Python, ce qui surestime une phrase de passe française. Il est en outre orthogonal à l’exigence qui fait le travail — la comparaison à la liste de fuites —, qu’il ne remplace pas.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Un modèle spécialisé auto-hébergé pour juger un mot de passe devrait être entraîné sur des mots de passe, c’est-à-dire sur des fuites, et il en garderait la trace dans ses poids. Il rendrait de surcroît un score là où ce contrôle doit rendre une décision et sa raison : un formulaire d’inscription doit dire à quelqu’un pourquoi son choix est refusé, et un nombre entre zéro et un n’est pas une phrase qu’on affiche.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Envoyer le mot de passe choisi par un utilisateur à un fournisseur tiers est la seule chose que cette fiche existe pour refuser : c’est le secret même, en clair, sorti de votre infrastructure, journalisé chez quelqu’un d’autre, pour obtenir un avis que quatre comparaisons de longueur et une requête en k-anonymat rendent exactement. Aucun gain imaginable ne compense la sortie de la donnée.
Pour aller plus loin
- NIST SP 800-63B-4 — les exigences sur les mots de passe
- Have I Been Pwned — l’API des mots de passe, et le modèle de k-anonymat
- Cloudflare — l’explication du protocole de recherche par plage
- zxcvbn — l’estimateur de robustesse, et le portage maintenu en TypeScript avec ses paquets de langue
Source des chiffres
- NIST SP 800-63B-4 (consultée) : « SHALL require passwords […] to be a minimum of 15 characters in length », « SHOULD permit a maximum password length of at least 64 characters », « SHALL NOT impose other composition rules », « SHALL NOT require subscribers to change passwords periodically », « SHALL compare the prospective secret against a blocklist »
- Have I Been Pwned, API v3 (consultée) : « The Pwned Passwords API is freely accessible without the need for a subscription and API key » ; l’adresse prend les cinq premiers caractères du SHA-1, et l’en-tête Add-Padding rend toutes les réponses de taille comparable
- zxcvbn sur PyPI (consulté) : version 4.5.0, publiée le 19 février 2025, licence MIT, dictionnaires anglais ; le paquet npm du même nom date de 2017, et c’est le portage @zxcvbn-ts, version 4.2.0 du 12 août 2026, qui publie des paquets de langue dont le français
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.