Generating a random password sounds simple — pick some random characters, done. But most naive implementations have a subtle flaw: they use a pseudorandom number generator (PRNG) that is predictable if an attacker knows its seed. For passwords, you need a cryptographically secure pseudorandom number generator (CSPRNG). Here's how to do it correctly in four languages.
The character set problem
A password's strength depends on two things: length and the size of the character pool it draws from. A password using only lowercase letters (26 characters) is much weaker than one using all printable ASCII (94 characters) at the same length.
| Character class | Pool size | Example characters |
|---|---|---|
| Lowercase letters | 26 | abcdefghijklmnopqrstuvwxyz |
| Uppercase letters | 26 | ABCDEFGHIJKLMNOPQRSTUVWXYZ |
| Digits | 10 | 0123456789 |
| Common symbols | 32 | `!@#$%^&*()_+-=[]{} |
| All printable ASCII | 94 | all of the above |
Entropy (bits) = length × log₂(pool size). A 16-character password from a 94-character pool has 16 × log₂(94) ≈ 104 bits of entropy — more than enough against brute force.
Why Math.random() is wrong for passwords
Math.random() and equivalent functions in other languages (Python's random.choice, PHP's rand()) use a seeded PRNG whose internal state can be inferred from a few outputs. They are designed for statistical randomness, not security. Always use the OS-provided CSPRNG for anything credential-related.
JavaScript
Browser
function generatePassword(length = 16, options = {}) {
const useLower = options.lowercase ?? true;
const useUpper = options.uppercase ?? true;
const useDigits = options.digits ?? true;
const useSymbols = options.symbols ?? false;
let charset = '';
if (useLower) charset += 'abcdefghijklmnopqrstuvwxyz';
if (useUpper) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (useDigits) charset += '0123456789';
if (useSymbols) charset += '!@#$%^&*()_+-=[]{}|;:,.<>?';
if (!charset) throw new Error('At least one character class must be selected');
const array = new Uint32Array(length);
crypto.getRandomValues(array); // CSPRNG — always available in browser and Node 19+
return Array.from(array, (n) => charset[n % charset.length]).join('');
}
// Usage
console.log(generatePassword(20, { symbols: true }));
// e.g. "aK9#mPx2@LqRt5!vNj8&"
crypto.getRandomValues is the Web Crypto API — available in all modern browsers and Node.js 19+ without importing anything. It reads from the OS entropy pool.
Node.js (older versions)
const { randomInt } = require('crypto'); // Node built-in
function generatePassword(length = 16, charset = null) {
charset ??= 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return Array.from({ length }, () => charset[randomInt(charset.length)]).join('');
}
crypto.randomInt(max) returns a cryptographically secure integer in [0, max).
Python
Python's secrets module (added in 3.6) is specifically designed for security-sensitive random generation.
import secrets
import string
def generate_password(
length: int = 16,
lowercase: bool = True,
uppercase: bool = True,
digits: bool = True,
symbols: bool = False,
) -> str:
charset = ''
if lowercase:
charset += string.ascii_lowercase
if uppercase:
charset += string.ascii_uppercase
if digits:
charset += string.digits
if symbols:
charset += string.punctuation
if not charset:
raise ValueError('At least one character class must be selected')
return ''.join(secrets.choice(charset) for _ in range(length))
# Guarantee at least one character from each required class
def generate_password_guaranteed(length: int = 16) -> str:
required = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice('!@#$%^&*'),
]
charset = string.ascii_letters + string.digits + '!@#$%^&*'
rest = [secrets.choice(charset) for _ in range(length - len(required))]
combined = required + rest
secrets.SystemRandom().shuffle(combined) # shuffle without bias
return ''.join(combined)
print(generate_password(20, symbols=True))
print(generate_password_guaranteed(20))
secrets.choice reads from os.urandom() under the hood. Never use random.choice for passwords — that module is seeded and predictable.
Go
package main
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
)
const (
lowerChars = "abcdefghijklmnopqrstuvwxyz"
upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
digitChars = "0123456789"
symbolChars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
)
type PasswordOptions struct {
Length int
Lower bool
Upper bool
Digits bool
Symbols bool
}
func GeneratePassword(opts PasswordOptions) (string, error) {
var charset strings.Builder
if opts.Lower {
charset.WriteString(lowerChars)
}
if opts.Upper {
charset.WriteString(upperChars)
}
if opts.Digits {
charset.WriteString(digitChars)
}
if opts.Symbols {
charset.WriteString(symbolChars)
}
pool := charset.String()
if len(pool) == 0 {
return "", fmt.Errorf("at least one character class must be selected")
}
result := make([]byte, opts.Length)
poolSize := big.NewInt(int64(len(pool)))
for i := range result {
n, err := rand.Int(rand.Reader, poolSize) // CSPRNG via crypto/rand
if err != nil {
return "", err
}
result[i] = pool[n.Int64()]
}
return string(result), nil
}
func main() {
pwd, err := GeneratePassword(PasswordOptions{
Length: 20, Lower: true, Upper: true, Digits: true, Symbols: true,
})
if err != nil {
panic(err)
}
fmt.Println(pwd)
}
crypto/rand.Reader reads from /dev/urandom on Linux/macOS and BCryptGenRandom on Windows — both are CSPRNGs. Never use math/rand for passwords.
PHP
<?php
function generatePassword(
int $length = 16,
bool $lowercase = true,
bool $uppercase = true,
bool $digits = true,
bool $symbols = false
): string {
$charset = '';
if ($lowercase) $charset .= 'abcdefghijklmnopqrstuvwxyz';
if ($uppercase) $charset .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if ($digits) $charset .= '0123456789';
if ($symbols) $charset .= '!@#$%^&*()_+-=[]{}|;:,.<>?';
if ($charset === '') {
throw new \InvalidArgumentException('At least one character class must be selected');
}
$charsetLength = strlen($charset);
$password = '';
for ($i = 0; $i < $length; $i++) {
// random_int() uses /dev/urandom or Windows CryptGenRandom — CSPRNG
$password .= $charset[random_int(0, $charsetLength - 1)];
}
return $password;
}
echo generatePassword(20, true, true, true, true) . PHP_EOL;
random_int() was added in PHP 7.0 and uses the OS CSPRNG. Never use rand() or mt_rand() for passwords — both are predictable Mersenne Twister PRNGs.
Quick reference
| Language | CSPRNG function | Avoid |
|---|---|---|
| JS (browser/Node 19+) | crypto.getRandomValues() |
Math.random() |
| Node.js (older) | crypto.randomInt() |
Math.random() |
| Python | secrets.choice() |
random.choice() |
| Go | crypto/rand.Int() |
math/rand |
| PHP | random_int() |
rand(), mt_rand() |
| CLI (Linux/macOS) | openssl rand -base64 24 |
— |
Ensuring character-class requirements
Many systems require "at least one uppercase, one digit, one symbol." The cleanest approach is:
- Generate one character from each required class.
- Fill the rest randomly from the full charset.
- Shuffle the result to avoid predictable positions (the required characters should not always appear at the start).
The Python example above shows this pattern. The same logic applies in any language — the key step is the shuffle using a CSPRNG, not random.shuffle (Python) or rand() (PHP).
6 common mistakes
1. Using PRNG instead of CSPRNG.
Math.random(), Python's random, rand(), and mt_rand() are all seeded and statistically predictable. Use the platform CSPRNG for anything security-related.
2. Modulo bias.
n % charset.length introduces a subtle bias when UINT_MAX is not divisible by charset.length. For most practical character set sizes this is negligible, but crypto.randomInt() and secrets.choice() handle this correctly by default.
3. Predictable positions for required characters. If you always place the required digit at index 0, the output is partially predictable. Shuffle with a CSPRNG after assembling.
4. Storing the plaintext password. Your generator should produce and immediately display the password — it should not be stored anywhere in plaintext. Store only a bcrypt/Argon2 hash in your database.
5. Not validating the minimum length. Short passwords are weak regardless of character set. Enforce a minimum of 12 characters; 16–20 is better.
6. Trusting the client for server-side generation. If you generate a password server-side (e.g., for initial account setup), generate and hash it on the server, not in the browser. Browser-generated passwords sent over the wire can be intercepted if TLS is misconfigured.
6 frequently asked questions
How long should a generated password be? 16 characters with lowercase + uppercase + digits + symbols gives ~104 bits of entropy — well above the 80-bit threshold. 12 characters is acceptable as an absolute minimum; 20+ for high-value accounts.
Can I use UUID as a password?
A UUID v4 has ~122 bits of entropy and looks like 550e8400-e29b-41d4-a716-446655440000. It's technically secure but hard to type. It also uses only hex characters plus dashes, which some systems don't accept. A proper 20-character random password is better.
Is a longer password with fewer character classes better than a shorter one with all classes? Length wins. A 20-character lowercase-only password has 20 × log₂(26) ≈ 94 bits of entropy. A 12-character full-charset password has 12 × log₂(94) ≈ 78 bits. Longer is always better — both length and character variety together are best.
What about passphrases (e.g., correct-horse-battery-staple)? Passphrases drawn from a large wordlist (e.g., EFF's 7776-word Diceware list) can be extremely secure and memorable. Four random words give ~51 bits; six words give ~77 bits. Use five or more words for security-critical accounts.
Should I exclude ambiguous characters like 0, O, l, 1?
For passwords that users will type by hand, yes — excluding them reduces transcription errors. For generated passwords stored in a password manager (never typed), there's no reason to restrict the pool.
How do I test my generator? Run it 100,000 times and check character-class distribution with a frequency analysis. Each class should appear roughly proportional to its share of the charset. Any obvious pattern — like symbols always appearing in the first two positions — indicates a bias bug.