---
title: 'Password Policies'
source: 'https://academia.sh/en/courses/authentication-and-authorization/password-policies'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:33+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```bash
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
```

```text
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.

```bash
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
```

```text
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.

```bash
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
```

```text
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.
