Generating Passwords for Test Accounts: Strength, Storage, Edge Cases and What Not to Commit
Test accounts end up with passwords like `Test1234!` because nobody thinks they matter. Then the staging database is restored from a backup onto a public URL, a QA credential in a README gets indexed, or a demo account shares a password with an admin. Test passwords do not need to be memorable, only unique, random and stored properly, and a generator makes that free. They are also the place to test how your own system handles unusual passwords.
How strong a test password needs to be
Strength comes from length and randomness, not from symbols sprinkled into a word. The entropy of a randomly generated password is length × log2(alphabet size).
alphabet per char 12 chars 16 chars 20 chars
digits (10) 3.3 bits 40 53 66
lower case (26) 4.7 bits 56 75 94
letters + digits (62) 5.95 bits 71 95 119
printable ASCII (94) 6.55 bits 79 105 131
For a test account on an internet-reachable staging system, 16 random characters from letters and digits (about 95 bits) is far beyond practical guessing. The "Test1234!"-style password has almost no entropy, because it is the first thing any credential-stuffing list tries.
Treat staging like production if it holds copies of real data, is reachable from the internet, or shares single sign-on or API keys with production. It often does all three.
Generating them from the command line and in code
Use a cryptographically secure random source. Math.random(), rand() and timestamp-based schemes are predictable.
# 16 random bytes as Base64: 24 characters including == padding
openssl rand -base64 16
# letters and digits only, 20 characters, from the kernel CSPRNG
LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20; echo
# pwgen: 5 secure (-s) passwords of 20 characters
pwgen -s 20 5
import secrets, string
alphabet = string.ascii_letters + string.digits
password = ''.join(secrets.choice(alphabet) for _ in range(20))
# one per test account
accounts = {f'qa-user-{i}@example.test': secrets.token_urlsafe(16) for i in range(1, 6)}
// Node.js
const { randomInt } = require('node:crypto');
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
const password = Array.from({ length: 20 }, () => alphabet[randomInt(alphabet.length)]).join('');
The Node.js alphabet above leaves out look-alike characters (0 O 1 l I), which helps when someone has to read a password off a screen or type it on a test device. It costs a little entropy per character; add length to compensate.
Characters that break things
A password that is secure but breaks the tooling around it wastes more time than it saves. For passwords that pass through scripts, configuration and spreadsheets, a letters-and-digits alphabet with more length is usually the better trade.
$, backtick, ! and \ are interpreted by shells inside double quotes, and ! triggers history expansion in interactive bash. # starts a comment in .env files and YAML unless the value is quoted. % has a special meaning in crontabs, and &, +, / and ? need encoding in URLs such as postgres://user:pass@host. ,, " and a leading = or + cause trouble in CSV files and spreadsheets, where a leading = is even treated as a formula. And ' breaks naively quoted SQL in seed scripts.
If a system requires symbols, choose from a safe subset such as -_.@, and still quote values in .env and YAML files.
Where test passwords should live
Not in the repository. Credentials committed to Git stay in its history after deletion and are copied to every clone and fork. GitHub secret scanning, GitGuardian and similar tools find them within minutes of a push to a public repository, and so do attackers.
Keep them in the same secret manager you use for production, such as 1Password, Bitwarden, Vault, AWS Secrets Manager or your CI's secret store, in a separate vault or path for non-production. Tests read them from environment variables. Seed scripts can generate a fresh random password at setup time and print it once, or write it straight into the secret store.
# .env.example — committed: names only
QA_ADMIN_EMAIL=qa-admin@example.test
QA_ADMIN_PASSWORD=
# .env — not committed (listed in .gitignore), filled from the secret manager
Use a reserved domain for test emails, such as example.test or a subdomain you control. Addresses at real domains, or at a typo of your own domain, can receive password-reset emails for accounts you forgot about.
Edge-case passwords worth testing
Test accounts are also where you check your own password handling. A few cases find real bugs.
length 64+ NIST SP 800-63B says verifiers should allow at least 64 characters
length 73+ bytes bcrypt only uses the first 72 bytes: two long passwords
sharing a 72-byte prefix both log in
leading/trailing space keep as typed: trim on both signup and login, or on neither
Unicode "pässwörd" normalise to NFC (or NFKC) before hashing, or macOS and
Windows keyboards produce different bytes
emoji "🔑🔑🔑🔑🔑🔑🔑🔑" 4 bytes each in UTF-8: can hit byte limits early
all digits must not be converted to a number (leading zeros lost)
The bcrypt limit catches many teams. If you use bcrypt, either reject passwords longer than 72 bytes or pre-hash them, and note that some implementations raise an error while others truncate silently. Argon2id, the current OWASP recommendation, has no such limit.
NIST SP 800-63B also recommends against composition rules, such as mandatory symbols and mixed case, and against periodic forced changes. It recommends checking new passwords against lists of breached ones instead. Test that the signup form really accepts a long passphrase of lower-case words.
Rotating and cleaning up
Rotate test credentials when a team member leaves, after a demo or pen-test, and whenever one has appeared in a chat, ticket or screenshot. Disable accounts that are no longer used rather than leaving them with known passwords. If staging is restored from production data, reset every real user's password hash there, or better, anonymise the data, so production passwords cannot be cracked from a staging copy.
Sources and further reading
NIST Special Publication 800-63B (Digital Identity Guidelines) covers password length, composition rules and breached-password checks. The OWASP Password Storage Cheat Sheet recommends Argon2id and documents bcrypt's 72-byte limit. The Python secrets module and Node.js crypto.randomInt documentation describe secure generation. RFC 2606 reserves .test and example.com for testing.
Frequently asked questions
How long should a test account password be?
At least 16 random characters from letters and digits, about 95 bits of entropy, for any system reachable from the internet. Length from a secure random generator matters far more than symbols.
Is it safe to use the same password for all test accounts?
No. One leaked credential then opens every account, and shared passwords spread into chats and documents. Generate a unique password per account and store them in a secret manager.
How do I generate a random password on the command line?
Use `openssl rand -base64 16`, `pwgen -s 20`, or `LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20`. All three use a cryptographically secure random source.
Can I commit test passwords to Git if the repository is private?
Avoid it. Private repositories get forked, mirrored, made public by mistake and cloned to laptops, and the password stays in history forever. Commit variable names in `.env.example` and keep values in a secret manager.
Which characters should I avoid in generated passwords?
For passwords used in scripts and config files, avoid the dollar sign, backtick, backslash, `!`, `#`, `%`, `&`, quotes, commas and a leading `=` or `+`, which shells, `.env` files, URLs and spreadsheets interpret. Letters and digits with extra length are just as strong.
Why does a very long password log in with the wrong ending?
If the system uses bcrypt, only the first 72 bytes are used, so any password sharing that prefix matches. Limit password length to 72 bytes, pre-hash, or switch to Argon2id.