I built my first password generator for a personal project years ago, using random.choice() on a string of letters and digits, and honestly thought I was done. It wasn’t until I read more carefully about how predictable Mersenne Twister output can be that I realized my “secure” password generator wasn’t secure at all. Rewriting it properly with the secrets module was a small change in code but a meaningful one in actual security guarantees. Here’s the complete, correct approach, along with everything I’ve learned about password generation policy along the way.
Why This Isn’t Just “Pick Random Characters”
A password generator has two distinct jobs: producing genuinely unpredictable output, and satisfying whatever composition rules (length, character variety, avoiding ambiguous characters) the target system requires. Getting the randomness wrong undermines the entire point of the password, no matter how well the composition rules are satisfied.
The Correct Foundation: secrets, Not random
import secrets
import string
def generate_password(length=16):
alphabet = string.ascii_letters + string.digits + string.punctuation
return "".join(secrets.choice(alphabet) for _ in range(length))
print(generate_password())
I use secrets.choice() specifically because passwords are a textbook security-sensitive use case. The official Python documentation itself uses almost exactly this pattern as the recommended way to generate passwords — it’s not just my personal preference, it’s the officially endorsed approach.
Compare this to the insecure version I started with years ago:
import random
import string
# Insecure — do not use for real passwords:
def insecure_generate_password(length=16):
alphabet = string.ascii_letters + string.digits + string.punctuation
return "".join(random.choice(alphabet) for _ in range(length))
The code looks almost identical, but the security guarantee is completely different. random.choice() draws from Mersenne Twister, whose internal state can be reconstructed from enough observed output; secrets.choice() draws from the OS’s cryptographically secure entropy source, which doesn’t have this vulnerability.
Building in Composition Requirements
Many systems require passwords to contain at least one uppercase letter, one lowercase letter, one digit, and one symbol. A naive approach might generate randomly and reject-and-retry until requirements are met, but a cleaner approach guarantees each category upfront, then fills the rest randomly and shuffles.
import secrets
import string
def generate_password_with_requirements(length=16):
if length < 4:
raise ValueError("Password length must be at least 4 to satisfy all requirements")
lowercase = string.ascii_lowercase
uppercase = string.ascii_uppercase
digits = string.digits
symbols = string.punctuation
# Guarantee at least one character from each required category
password_chars = [
secrets.choice(lowercase),
secrets.choice(uppercase),
secrets.choice(digits),
secrets.choice(symbols),
]
# Fill the rest from the combined pool
all_chars = lowercase + uppercase + digits + symbols
password_chars += [secrets.choice(all_chars) for _ in range(length - 4)]
# Shuffle so the guaranteed characters aren't always in the same position
# secrets doesn't have a built-in shuffle, so we implement Fisher-Yates manually
for i in range(len(password_chars) - 1, 0, -1):
j = secrets.randbelow(i + 1)
password_chars[i], password_chars[j] = password_chars[j], password_chars[i]
return "".join(password_chars)
print(generate_password_with_requirements(16))
I implement a manual Fisher-Yates shuffle here because random.shuffle() uses the insecure random generator internally, and there’s no direct secrets.shuffle() equivalent in the standard library. The Fisher-Yates algorithm itself works by iterating from the end of the list backward, at each step swapping the current element with a randomly chosen earlier (or equal-index) element — this produces a uniformly random permutation, and doing it with secrets.randbelow() for the random index keeps the entire process cryptographically secure.
Excluding Ambiguous Characters
Some contexts (like passwords a person needs to type manually, or read off a printed card) benefit from excluding visually ambiguous characters like 0/O, 1/l/I.
import secrets
def generate_readable_password(length=16):
# Excludes 0, O, 1, l, I and similar ambiguous characters
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
return "".join(secrets.choice(alphabet) for _ in range(length))
print(generate_readable_password(12))
This trades off a slightly smaller character pool (and therefore marginally less entropy per character) for real-world usability — a password that’s technically stronger but gets mistyped constantly because of ambiguous characters isn’t actually serving its purpose well.
Generating Memorable Passphrases (Diceware-Style)
An alternative, increasingly recommended approach — particularly popularized by security researchers and organizations like the EFF — is generating a passphrase from a list of common words rather than a string of random characters. This can be easier for humans to remember while still providing strong entropy, provided the word list is large enough and enough words are used.
import secrets
# In practice, use a large, well-vetted word list (thousands of words) —
# this short list is illustrative only, not suitable for real use
word_list = ["correct", "horse", "battery", "staple", "mountain", "river", "cloud", "forest"]
def generate_passphrase(num_words=4, separator="-"):
words = [secrets.choice(word_list) for _ in range(num_words)]
return separator.join(words)
print(generate_passphrase())
The security of this approach depends heavily on the size of the word list — a list of 7,776 words (a common Diceware standard, chosen because it’s 6^5, matching five dice rolls) with 6 randomly chosen words gives substantially more entropy than a short, casual list like the illustrative one above. I’d never use a small hardcoded list like the one shown for a genuinely important passphrase — always source a proper, large, well-vetted word list for real use.
Understanding Entropy: How Strong Is “Strong Enough”?
Password strength is measured in entropy, expressed in bits, calculated as:
entropy_bits = length * log2(alphabet_size)
import math
def calculate_entropy(length, alphabet_size):
return length * math.log2(alphabet_size)
# A 16-character password from a 94-character set (letters, digits, symbols)
print(calculate_entropy(16, 94)) # roughly 105 bits
# A 4-word passphrase from a 7776-word Diceware list
print(calculate_entropy(4, 7776)) # roughly 51.7 bits
# A 6-word passphrase from the same list
print(calculate_entropy(6, 7776)) # roughly 77.5 bits
Higher entropy means exponentially more possible combinations for an attacker to search through in a brute-force attack. As a rough modern guideline, many security references suggest aiming for at least 60-80 bits of entropy for typical account passwords, with more for anything protecting especially sensitive systems — but recommendations do evolve over time as computing power for brute-forcing increases, so it’s worth checking current guidance (such as NIST’s password guidelines) rather than treating any specific number as permanently fixed.
Hashing Passwords for Storage (A Critical Related Topic)
Generating a random password is only half the picture — if you’re building a system that stores user passwords (rather than just generating one to hand to a user), you must never store passwords in plain text. This is a distinct topic from generation, but critical enough to mention here since the two are so often confused.
import hashlib
import secrets
def hash_password(password: str):
salt = secrets.token_bytes(16)
# PBKDF2 is one reasonable choice; dedicated libraries like bcrypt or argon2 are also excellent options
hashed = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000)
return salt, hashed
def verify_password(password: str, salt: bytes, expected_hash: bytes):
hashed = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000)
return secrets.compare_digest(hashed, expected_hash)
salt, hashed = hash_password("my-generated-password")
print(verify_password("my-generated-password", salt, hashed)) # True
I want to stress: generating a secure random password (this article’s core topic) and securely storing user passwords (hashing, salting) are related but genuinely separate concerns, and conflating them is a common source of confusion. If you’re building account systems, dedicated, well-audited libraries (like bcrypt or argon2-cffi) are generally preferable to rolling your own PBKDF2 logic, since password hashing has many subtle correctness and performance considerations beyond the scope of generation alone.
Real-World Applications
- Automated account provisioning, generating a temporary password for a new user that they’ll change on first login.
- API key and service account credential generation.
- Password managers, generating strong, unique passwords per site.
- Temporary access codes, like one-time invite codes for signup flows.
- Infrastructure automation, generating random database or service credentials during provisioning scripts.
Common Mistakes
Using random instead of secrets. This is the single most important and most common mistake — a password generator built on random.choice() is not actually cryptographically secure, however random it looks.
Generating passwords that are too short or from too small a character pool, resulting in low entropy that’s vulnerable to brute-force attacks despite looking “complex.”
Confusing password generation with password storage. A perfectly generated random password doesn’t help if the system storing it saves it in plain text or with weak hashing.
Using random.shuffle() when trying to build a “secure” password with guaranteed character categories — this reintroduces the insecure generator right at the final step, undermining all the careful work done with secrets.choice() earlier.
Hardcoding a small, guessable word list for passphrase generation and mistakenly believing it provides similar security to a properly sized Diceware list.
Debugging Tips
- Verify your password generator function actually produces the required character categories by running it many times and checking the distribution — a subtle bug in category-guarantee logic can silently produce passwords missing a required character type.
- Calculate and log the entropy of generated passwords during testing to sanity-check that your chosen length and alphabet size actually meet your security target.
- If integrating with an external system’s password policy, test against their actual validation rules (some systems have unusual restrictions, like disallowing certain symbols) rather than assuming a generic password will always be accepted.
Performance Considerations
secrets-based generation is slower thanrandom-based generation, but the absolute time cost for generating a single password is negligible in virtually any real application — this is not a context where the performance difference matters practically.- For bulk operations (e.g., provisioning thousands of test accounts), the cumulative overhead of
secretsversusrandombecomes more noticeable, but security-sensitive credential generation should still usesecretsregardless — this isn’t a place to trade security for speed.
FAQs
Is 16 characters long enough for a password? For most modern applications, 16 characters from a large alphabet (letters, digits, symbols) provides strong entropy, but requirements vary — always check whether you’re meeting a specific compliance standard or organizational policy with its own explicit length requirements.
Should I use random characters or a passphrase? Both can be secure if sized appropriately — passphrases are often easier for humans to remember and type correctly, while random character strings can pack more entropy into fewer characters. The right choice depends on context and who needs to use the password.
Can I use random.shuffle() safely if I only used secrets.choice() to pick the characters? No — shuffling with random.shuffle() reintroduces the insecure generator into the process. Implement a manual Fisher-Yates shuffle using secrets.randbelow() instead, as shown above.
Do I need a special library, or is the standard library enough? For generation, the standard library (secrets and string) is entirely sufficient and is exactly what Python’s own documentation recommends. For password storage/hashing, dedicated third-party libraries like bcrypt or argon2-cffi are generally preferred over rolling your own.
Summary
Generating a genuinely secure random password in Python means starting from the secrets module rather than random, since only secrets draws from the operating system’s cryptographically secure entropy source. From there, you can guarantee character-category requirements, exclude ambiguous characters for readability, or build memorable Diceware-style passphrases — as long as every random choice along the way, including any shuffling, stays within secrets rather than accidentally reintroducing the insecure random module. Understanding entropy helps quantify exactly how strong a given password scheme actually is, and remembering that generation and secure storage are two separate concerns rounds out a complete, correct approach to password handling in Python.
References
- Python official documentation:
secretsmodule — Generating secure tokens and passwords - Python official documentation:
stringmodule - Python official documentation:
hashlibmodule - PEP 506, “Adding A Secrets Module To The Standard Library,” on peps.python.org
