Skip to content
academia.sh

Lesson 14 / 23

Password Policies

How complexity rules narrow user behavior, the real difference a periodic change rule adds, checking against a breached password list, and a hash-prefix query that never exposes the password.

Contents

The previous lesson’s grouping query showed one thing: three of eight members had picked the same string, library. Salt keeps these three records from resembling each other, and a slow hash makes testing each one expensive — but neither changes the choice itself. Where server-side defenses end is where password policy begins.

A policy is the rule that tells the user which password gets accepted. It looks easy to write, but it is where usability and security collide most directly: the stricter the rule, the more the user follows a predictable path that satisfies it with least effort. This lesson measures which rule actually works.

The Space a Rule Promises and the Pattern Users Produce

A password policy’s value is not measured by the search space it theoretically opens up. The measure is the space of the pattern users actually produce in response to the rule.

Take the rule “at least eight characters, uppercase, lowercase, a digit, and a symbol required.” It specifies eight positions out of a set of ninety-four characters. The user’s lowest-effort way to satisfy it is predictable: take a known word, capitalize its first letter, append one or two digits and a symbol. The resulting pattern’s space is far smaller than the space the rule promises.

The script below computes this gap for four policies. The pattern spaces are a model; the assumptions are written explicitly into the code and can be changed and recomputed.

cat > policy.mjs <<'EOF'
const bit = (n) => Math.log2(n);

const DICTIONARY = 20000;      // base word count the user picks from (model)
const policies = [
  {
    name: "min 8 chars, four character types",
    theoretical: 94 ** 8,
    // Shortest path that satisfies the rule: word + capital first letter + 1-2 digits + 1 symbol
    pattern: DICTIONARY * 1 * 110 * 10,
    source: "word + Capital letter + digit + symbol",
  },
  { name: "min 12 chars, no rule", theoretical: 26 ** 12,
    pattern: DICTIONARY * DICTIONARY * 100, source: "two words + digit" },
  { name: "min 16 chars, no rule", theoretical: 26 ** 16,
    pattern: DICTIONARY ** 3 * 10, source: "three words + digit" },
  { name: "4-word passphrase (2048-word list)", theoretical: 2048 ** 4,
    pattern: 2048 ** 4, source: "random pick, no pattern" },
];

console.log("policy                             | theory   | pattern  | loss  | source of the pattern");
console.log("-----------------------------------|----------|----------|-------|----------------------------");
for (const p of policies) {
  console.log(
    p.name.padEnd(34) + " | " +
    (bit(p.theoretical).toFixed(1) + " bit").padStart(8) + " | " +
    (bit(p.pattern).toFixed(1) + " bit").padStart(8) + " | " +
    (bit(p.theoretical / p.pattern).toFixed(1)).padStart(5) + " | " + p.source
  );
}

console.log("\nchanging every 90 days, if the user advances a trailing counter:");
for (const period of [1, 4, 8, 20]) {
  const space = DICTIONARY * 110 * 10 * period;
  console.log(`  after ${String(period).padStart(2)} periods: ${bit(space).toFixed(1)} bit ` +
    `(difference from the first period: ${(bit(space) - bit(DICTIONARY * 110 * 10)).toFixed(1)} bit)`);
}
EOF
node policy.mjs
policy                             | theory   | pattern  | loss  | source of the pattern
-----------------------------------|----------|----------|-------|----------------------------
min 8 chars, four character types  | 52.4 bit | 24.4 bit |  28.0 | word + Capital letter + digit + symbol
min 12 chars, no rule              | 56.4 bit | 35.2 bit |  21.2 | two words + digit
min 16 chars, no rule              | 75.2 bit | 46.2 bit |  29.0 | three words + digit
4-word passphrase (2048-word list) | 44.0 bit | 44.0 bit |   0.0 | random pick, no pattern

changing every 90 days, if the user advances a trailing counter:
  after  1 periods: 24.4 bit (difference from the first period: 0.0 bit)
  after  4 periods: 26.4 bit (difference from the first period: 2.0 bit)
  after  8 periods: 27.4 bit (difference from the first period: 3.0 bit)
  after 20 periods: 28.7 bit (difference from the first period: 4.3 bit)

The first row shows the policy’s most common form: the rule promises fifty-two bits, the user’s pattern delivers twenty-four. The twenty-eight-bit gap is a direct result of the direction the rule pushes the user — forced complexity gets added at the cheapest point, the end.

The second and third rows show what length does. With no character-class rule at all, raising the minimum length alone pushes the pattern space from twenty-four bits to thirty-five, then forty-six. Length forces more choices; complexity only forces decoration of the same choice.

The fourth row shows the limit: when the choice goes to a random generator instead of the user, no pattern is left, and the theoretical space equals the real one — forty-four bits, twenty more than the first row, and easier to remember besides.

The Periodic Change Rule

The output’s second part is the balance sheet of a common operational rule. Requiring a password change every ninety days adds a total of four and a half bits over twenty periods, if the user just advances a trailing counter — the return on the same user typing the same password twenty times.

The other side of the ledger holds a cost that cannot be measured but can be observed: reset requests rising at the start of every period, written-note behavior, and a predictable trailing counter. The one defensible form is conditional — a change required only on a sign the password has leaked. Calendar-driven mandatory change does about as much harm as the bits it measurably adds.

Checking Against a Breached Password List

The pattern table’s real conclusion: steering the user’s choice with rules is a weak tool; rejecting the choice outright is the strong one. A password already known to have leaked belongs in every candidate list no matter how many rules it satisfies, and it should be rejected.

Applying this check runs into one problem: the list is large, and sending the user’s chosen password to whoever holds it is unacceptable. The solution is querying on a prefix of the password’s hash instead of the password itself. The server returns every record matching that prefix, and the client decides in its own memory whether the password is in that set. All the server learns is the prefix; which record was actually being searched for stays ambiguous — an ambiguity called k-anonymity, where k is the number of records in the returned set.

Prefix length is a trade-off. A short prefix returns a bigger bucket — more privacy, more data transferred. A long prefix does the opposite. The script below measures this trade-off on a local list.

cat > prefix.mjs <<'EOF'
import { createHash } from "node:crypto";

const root = ["library","book","reading","branch","sea","plane","summer","winter","member","loan",
              "desk","shelf","card","fine","return","catalog","hall","pen","ledger","note"];

function* list(n = 200000) {                     // local, generated sample list
  yield* ["library", "shore-branch", "loan123"];
  for (let i = 0; i < n; i++) {
    yield `${root[i % 20]}-${root[Math.floor(i / 20) % 20]}-${Math.floor(i / 400) % 500}`;
  }
}

const hashes = [...list()].map((p) =>
  createHash("sha1").update(p).digest("hex").toUpperCase());
console.log(`local list: ${hashes.length.toLocaleString("en-US")} records`);

console.log("\nprefix | bucket count | avg bucket | largest bucket | what the server sees");
console.log("-------|--------------|------------|-----------------|----------------------");
for (const length of [2, 3, 4, 5]) {
  const bucket = new Map();
  for (const h of hashes) {
    const p = h.slice(0, length);
    bucket.set(p, (bucket.get(p) ?? 0) + 1);
  }
  const sizes = [...bucket.values()];
  const avg = sizes.reduce((a, b) => a + b, 0) / sizes.length;
  const largest = sizes.reduce((a, b) => (b > a ? b : a), 0);
  console.log(
    ` ${length}   | ${String(bucket.size).padStart(12)} | ${avg.toFixed(1).padStart(10)} | ` +
    `${String(largest).padStart(15)} | ${(length * 4)} bit / 160 bit`
  );
}
EOF
node prefix.mjs
local list: 200,003 records

prefix | bucket count | avg bucket | largest bucket | what the server sees
-------|--------------|------------|-----------------|----------------------
 2   |          256 |      781.3 |             844 | 8 bit / 160 bit
 3   |         4096 |       48.8 |              78 | 12 bit / 160 bit
 4   |        62454 |        3.2 |              12 | 16 bit / 160 bit
 5   |       182069 |        1.1 |               5 | 20 bit / 160 bit

The table shows that prefix length has to be chosen against list size. In this two-hundred-thousand-record list, a four-character prefix returns about three records on average — a bucket size where k-anonymity stops meaning anything. Three characters gives an average bucket of about forty-nine records, and the server sees only twelve of the hash’s hundred sixty bits. When the list grows to billions of records, the same bucket size needs a five-character prefix — the rule is choosing prefix length so the bucket size holds at the target k.

The hash function chosen here is a separate matter. This computation distributes records across prefix buckets, not stores a password; speed is a requirement here, not a flaw. The slow-hash rule from the previous two lessons applies to the storage path, not to this index key.

Running the Query

The block below stands up a local range service and checks two passwords: library, shared by members in the previous lesson, and sea-shell-7, U-1001’s choice. The only thing that goes out in the request is the three-character prefix.

cat > range-server.mjs <<'EOF'
import { createServer } from "node:http";
import { createHash } from "node:crypto";

const root = ["library","book","reading","branch","sea","plane","summer","winter","member","loan",
              "desk","shelf","card","fine","return","catalog","hall","pen","ledger","note"];
function* list(n = 200000) {
  yield* ["library", "shore-branch", "loan123"];
  for (let i = 0; i < n; i++) {
    yield `${root[i % 20]}-${root[Math.floor(i / 20) % 20]}-${Math.floor(i / 400) % 500}`;
  }
}

const PREFIX = 3;                                   // chosen for this list size
const bucket = new Map();
for (const p of list()) {
  const h = createHash("sha1").update(p).digest("hex").toUpperCase();
  const prefix = h.slice(0, PREFIX);
  if (!bucket.has(prefix)) bucket.set(prefix, []);
  bucket.get(prefix).push(h.slice(PREFIX));
}

createServer((request, response) => {
  const path = new URL(request.url, "http://local").pathname;
  if (path === "/health") return response.writeHead(200).end("ready\n");
  const match = /^\/range\/([0-9A-F]{3})$/.exec(path);
  if (!match) return response.writeHead(404).end("");
  response.writeHead(200, { "content-type": "text/plain" });
  response.end((bucket.get(match[1]) ?? []).join("\n") + "\n");
}).listen(8493, "127.0.0.1");
EOF
node range-server.mjs & server=$!
until curl -sf http://127.0.0.1:8493/health > /dev/null; do :; done

hash() { node -e 'const {createHash}=require("node:crypto");
  process.stdout.write(createHash("sha1").update(process.argv[1]).digest("hex").toUpperCase())' "$1"; }

for password in library sea-shell-7; do
  full=$(hash "$password"); prefix=${full:0:3}; tail=${full:3}
  curl -s "http://127.0.0.1:8493/range/$prefix" -o bucket.txt
  echo "password    : $password"
  echo "  prefix sent: $prefix   (first 3 characters of the full hash)"
  echo "  bucket size: $(wc -l < bucket.txt | tr -d ' ') tails"
  if grep -qx "$tail" bucket.txt; then echo "  result     : IN the list, rejected"
  else echo "  result     : not in the list, accepted"; fi
done

kill $server
rm -f bucket.txt range-server.mjs
password    : library
  prefix sent: 002   (first 3 characters of the full hash)
  bucket size: 52 tails
  result     : IN the list, rejected
password    : sea-shell-7
  prefix sent: 252   (first 3 characters of the full hash)
  bucket size: 41 tails
  result     : not in the list, accepted

The client is the party that decides; the server only returns a bucket and never learns which record was queried. Prefix values and bucket sizes depend on the list and the hash function; the flow itself does not change. The same procedure works just as well with a list kept on your own infrastructure — you already hold the list there, and the prefix layer still keeps the checking service from ever seeing the password.

Where the check runs is a separate decision. It is mandatory on registration and password-change flows, and it can also run at login: once the user signs in correctly, that password is available and can be checked; if it turns up on the list, the user is prompted to change it — at the same point as the parameter upgrade from the previous lessons.

The Policy, Written Out

The measurements so far converge on a single policy.

Set a minimum length, not a maximum. An eight-character floor is low; twelve is more defensible. A ceiling is unnecessary — the hash already produces fixed-length output. A field that cuts off at forty characters forces a password-manager user to type it by hand.

Do not require character classes. Their measured effect is pushing the user into a predictable pattern. Length and the breach-list check take the rule’s place.

Accept every character. Spaces, punctuation, and non-ASCII letters included. Every character set that gets restricted narrows the user’s space of choice. The normalization step established in the previous lesson is what makes this acceptance safe.

Do not block pasting. A field that blocks pasting makes using a password manager impossible and pushes the user toward a memorable — that is, weak — password.

Give feedback while typing. The user should see whether the password will be accepted as they type it; a form that rejects it after submission pushes the user toward satisfying the rule with the least effort.

Make the list check mandatory. Checking against a breached password list is more effective than every character rule combined, and the method above does it without exposing the password.

Summary

  • A policy’s value is measured by the pattern space users produce in response to the rule, not by the theoretical search space.
  • Requiring character classes narrows the pattern space; raising the minimum length produces a wider space from the same user behavior.
  • Calendar-driven periodic change adds four and a half bits over twenty periods; its defensible form is conditional change tied to a sign of a leak.
  • Checking against a breached password list is more effective than steering the user’s choice with rules.
  • Querying by hash prefix checks without exposing the password or the full hash; prefix length is chosen to hold the bucket size at the target k.

Next Step

However well a password is chosen, it gets forgotten, and every system has to offer a way back in. That path is a second door bypassing authentication entirely: built wrong, it lets someone who has never known the password into the account. The next lesson covers the reset flow’s attack surface — a token that is single-use, short-lived, and stored as a hash, returning the same response against user enumeration, and what a reset does to sessions.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close