1. The Problem
Build a secure password generator that creates passwords of a specified length using a configurable mix of uppercase, lowercase, digits, and symbols. Guarantee at least one of each required character type. Rate the strength of each password.
Why this project? String manipulation, configurable options objects, the Fisher-Yates shuffle (avoiding biased randomness), and security thinking.
2. How to Think About This
The naive approach (pick randomly from the full pool) can produce a "strong" password that accidentally has no digits or no symbols โ failing its own requirements. The fix: first pick one character from each required set, then fill the rest from the combined pool, then shuffle so the guaranteed characters aren't always first.
Shuffle must be Fisher-Yates (not .sort(() => Math.random() - 0.5)
which is statistically biased).
3. The Build
import { argv } from "process";
const CHARS = {
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
lower: "abcdefghijklmnopqrstuvwxyz",
digits: "0123456789",
symbols: "!@#$%^&*()-_=+[]{}|;:,<>?",
};
// Pick one random character from a string
function pick(str) {
return str[Math.floor(Math.random() * str.length)];
}
// Fisher-Yates shuffle โ unbiased, in-place
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]; // swap
}
return arr;
}
// Generate a password with given options
export function generatePassword({
length = 16,
upper = true,
lower = true,
digits = true,
symbols = true,
} = {}) {
if (length < 4 || length > 128) throw new RangeError("Length must be 4โ128");
const sets = [
upper ? CHARS.upper : "",
lower ? CHARS.lower : "",
digits ? CHARS.digits : "",
symbols ? CHARS.symbols : "",
].filter(Boolean);
if (!sets.length) throw new Error("Select at least one character type");
const pool = sets.join("");
// Guarantee one from each active set:
const guaranteed = sets.map(set => pick(set));
// Fill the rest from the full pool:
const rest = Array.from({ length: length - guaranteed.length }, () => pick(pool));
// Shuffle so guaranteed chars aren't always at the start:
return shuffle([...guaranteed, ...rest]).join("");
}
// Strength rating (0โ5)
export function strength(pw) {
let score = 0;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/\d/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
const labels = ["Very Weak","Weak","Fair","Good","Strong","Very Strong"];
return { score, label: labels[Math.min(score, 5)] };
}
// CLI: node pwgen.mjs [length] [count]
const length = parseInt(argv[2] ?? "16", 10) || 16;
const count = parseInt(argv[3] ?? "5", 10) || 5;
if (isNaN(length) || length < 4 || length > 128) {
console.error("Length must be 4โ128"); process.exit(1);
}
console.log(`Generating ${count} password${count===1?"":"s"} (length ${length}):
`);
for (let i = 0; i < count; i++) {
const pw = generatePassword({ length });
const { label } = strength(pw);
console.log(` ${pw} [${label}]`);
}
4. Test & Prove
import { generatePassword, strength } from "./pwgen.mjs";
let pass=0,fail=0;
function test(desc,fn){try{fn();console.log(" โ",desc);pass++;}catch(e){console.log(" โ",desc,"โ",e.message);fail++;}}
// Statistical: run many times to check invariants
const N = 500;
test("always correct length", () => {
for(let i=0;i<N;i++){const pw=generatePassword({length:12});if(pw.length!==12)throw new Error(pw.length);}
});
test("always has uppercase (when enabled)", () => {
for(let i=0;i<N;i++){const pw=generatePassword({upper:true,lower:true,digits:true,symbols:false});
if(!/[A-Z]/.test(pw))throw new Error("missing upper: "+pw);}
});
test("always has digit (when enabled)", () => {
for(let i=0;i<N;i++){const pw=generatePassword();if(!/\d/.test(pw))throw new Error("missing digit: "+pw);}
});
test("digits-only contains no letters", () => {
for(let i=0;i<50;i++){const pw=generatePassword({upper:false,lower:false,digits:true,symbols:false});
if(/[a-zA-Z!@#]/.test(pw))throw new Error("found non-digit: "+pw);}
});
test("throws on length < 4", () => { try{generatePassword({length:2});throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });
test("throws on no char sets", () => { try{generatePassword({upper:false,lower:false,digits:false,symbols:false});throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });
test("16-char password rates Strong or better", () => {
const pw=generatePassword({length:16}); const {score}=strength(pw); if(score<4)throw new Error(score);
});
console.log(\`
\${pass} passed, \${fail} failed\`);
5. The Interface
Input: optional CLI args โ node pwgen.mjs [length] [count]. Defaults: length 16, count 5.
Output: one password per line with its strength rating.
$ node pwgen.mjs 20 3 Generating 3 passwords (length 20): Kx#2mPqL9!vRt$nYeJ@8 [Very Strong] 7bN*cWd4^sFhZo!rPuQ1 [Very Strong] Gm$8kVn#2LpRt!xYeJ9@ [Very Strong]
6. Run It
node pwgen.mjs # 5 passwords, length 16
node pwgen.mjs 24 # 5 passwords, length 24
node pwgen.mjs 8 10 # 10 passwords, length 8
node pwgen.test.mjs # run tests (500 iterations)๐ฏ Try this next
- Use
crypto.getRandomValues()(browser) orcrypto.randomInt()(Node) for cryptographically secure randomness - Add a
--no-symbolsflag usingprocess.argvparsing - Add a "pronounceable" mode โ alternating consonant/vowel for easier memorisation
- Build a browser version with a copy-to-clipboard button using
navigator.clipboard.writeText()
What you practised
Default parameter objects, guaranteed character inclusion via seeded arrays,
Fisher-Yates shuffle (the correct shuffle algorithm), statistical testing (run 500 times),
and the difference between biased .sort(Math.random) and unbiased Fisher-Yates.
Reference: Arrays ยท Functions ยท Regular Expressions