Password Hashing Best Practices
This guide covers secure password storage - one of the most critical security tasks in application development. Poor password hashing is responsible for countless data breaches.
The Golden Rules
- Never store passwords in plain text
- Never use MD5 or SHA-1 for passwords
- Never use fast hash functions (SHA-256, BLAKE3) alone for passwords
- Never use the same salt for all users
- Never write your own password hashing scheme
- Use Argon2id (recommended) or scrypt/bcrypt
- Generate a unique random salt per password
- Use appropriate cost parameters (tune for ~100ms-500ms)
- Use constant-time comparison when verifying
- Upgrade hashing on user login as hardware improves
Why Password Hashing is Different
Regular hash functions (SHA-256, BLAKE3) are designed to be fast. That’s terrible for passwords because:
SHA-256: ~10 million hashes/second on a CPU
~10 billion hashes/second on a GPU
Argon2id: ~10 hashes/second (with proper parameters)(Numbers are order-of-magnitude examples; real values depend on hardware, drivers, and implementation.)
An attacker with a stolen database can try billions of password guesses per second with fast hashes, but only a handful with Argon2.
The Attack Scenario
- Attacker steals your user database
- Database contains password hashes
- Attacker runs offline brute-force attack
- No rate limiting, no account lockouts - just raw computing power
Your defense: Make each password guess computationally expensive.
Algorithm Comparison
| Algorithm | Memory-Hard | Recommended | Notes |
|---|---|---|---|
| Argon2id | ✅ Yes | ✅ Best choice | RFC 9106, PHC winner |
| scrypt | ✅ Yes | ✅ Good | RFC 7914, proven |
| bcrypt | ⚠️ Limited | ⚠️ Acceptable | 72-byte password limit |
| PBKDF2 | ❌ No | ⚠️ Legacy only | FIPS compliant, GPU-friendly |
| SHA-256 | ❌ No | ❌ Never | Too fast |
| MD5 | ❌ No | ❌ Never | Broken and too fast |
Argon2id
Argon2 won the Password Hashing Competition (2015) and is standardized in RFC 9106. Use Argon2id which combines:
- Argon2i’s side-channel resistance
- Argon2d’s GPU/ASIC resistance
Basic Password Hashing
The examples on this page create and verify standard Argon2id hashes with the corrected implementation in 2026.10.0 and later. Hashes written by 2025.11.0 through 2026.9.1 need the migration guide.
#include <cryptopp/argon2.h>
#include <cryptopp/osrng.h>
#include <cryptopp/hex.h>
#include <cryptopp/secblock.h>
#include <iostream>
#include <string>
using namespace CryptoPP;
struct PasswordHash {
SecByteBlock salt;
SecByteBlock hash;
// Parameters (store these with the hash!)
uint32_t iterations;
uint32_t memory; // KB
uint32_t parallelism;
};
PasswordHash hashPassword(const std::string& password) {
AutoSeededRandomPool rng;
PasswordHash result;
// Generate random 16-byte salt
result.salt.resize(16);
rng.GenerateBlock(result.salt, result.salt.size());
// Argon2id parameters (tune for your hardware!)
result.iterations = 3; // Time cost
result.memory = 65536; // 64 MB memory
result.parallelism = 4; // 4 threads
// Derive 32-byte hash
result.hash.resize(32);
Argon2 argon2;
argon2.DeriveKey(
result.hash, result.hash.size(),
(const byte*)password.data(), password.size(),
result.salt, result.salt.size(),
result.iterations,
result.memory,
result.parallelism
);
return result;
}
bool verifyPassword(const std::string& password, const PasswordHash& stored) {
// Re-derive hash with same parameters
SecByteBlock computed(stored.hash.size());
Argon2 argon2;
argon2.DeriveKey(
computed, computed.size(),
(const byte*)password.data(), password.size(),
stored.salt, stored.salt.size(),
stored.iterations,
stored.memory,
stored.parallelism
);
// CRITICAL: Use constant-time comparison!
return VerifyBufsEqual(computed, stored.hash, stored.hash.size());
}
int main() {
std::string password = "correct horse battery staple";
// Hash password (during registration)
PasswordHash stored = hashPassword(password);
// Verify password (during login)
bool valid = verifyPassword(password, stored);
std::cout << "Password valid: " << (valid ? "YES" : "NO") << std::endl;
// Wrong password
bool invalid = verifyPassword("wrong password", stored);
std::cout << "Wrong password: " << (invalid ? "YES" : "NO") << std::endl;
return 0;
}Parameter Selection
Choose parameters based on your security requirements and hardware:
Interactive Logins (Web/Mobile)
Target: 100-500ms response time
// Moderate security - interactive logins
uint32_t iterations = 3;
uint32_t memory = 65536; // 64 MB
uint32_t parallelism = 4;High Security (Encryption Keys, Sensitive Data)
Target: 500ms-1000ms response time
// High security - disk encryption, sensitive data
uint32_t iterations = 4;
uint32_t memory = 131072; // 128 MB
uint32_t parallelism = 4;Resource-Constrained (Mobile, Embedded)
Target: 100-250ms on limited hardware
// Resource-constrained environments
uint32_t iterations = 3;
uint32_t memory = 16384; // 16 MB
uint32_t parallelism = 2;Tuning Parameters
Always benchmark on your target hardware:
#include <chrono>
void tuneArgon2Parameters() {
AutoSeededRandomPool rng;
SecByteBlock salt(16), hash(32);
rng.GenerateBlock(salt, salt.size());
std::string testPassword = "benchmark_password";
// Test different memory values
for (uint32_t memory : {16384, 32768, 65536, 131072}) {
auto start = std::chrono::high_resolution_clock::now();
Argon2 argon2;
argon2.DeriveKey(
hash, hash.size(),
(const byte*)testPassword.data(), testPassword.size(),
salt, salt.size(),
3, // iterations
memory, // memory in KB
4 // parallelism
);
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "Memory: " << memory << " KB, Time: "
<< duration.count() << " ms" << std::endl;
}
}scrypt Alternative
If you can’t use Argon2, scrypt (RFC 7914) is a good alternative:
#include <cryptopp/scrypt.h>
#include <cryptopp/osrng.h>
#include <cryptopp/secblock.h>
using namespace CryptoPP;
SecByteBlock hashWithScrypt(const std::string& password,
const SecByteBlock& salt) {
SecByteBlock derived(32);
Scrypt scrypt;
scrypt.DeriveKey(
derived, derived.size(),
(const byte*)password.data(), password.size(),
salt, salt.size(),
1 << 15, // N = 32768 (CPU/memory cost)
8, // r = 8 (block size)
1 // p = 1 (parallelization)
);
return derived;
}scrypt Parameters
| Parameter | Meaning | Typical Value |
|---|---|---|
| N | CPU/memory cost (power of 2) | 2^14 to 2^20 |
| r | Block size | 8 |
| p | Parallelization | 1 |
Memory usage: 128 * N * r bytes
PBKDF2 (Legacy Only)
Only use PBKDF2 when required for FIPS compliance or legacy compatibility:
#include <cryptopp/pwdbased.h>
#include <cryptopp/sha.h>
#include <cryptopp/secblock.h>
using namespace CryptoPP;
SecByteBlock hashWithPBKDF2(const std::string& password,
const SecByteBlock& salt) {
SecByteBlock derived(32);
PKCS5_PBKDF2_HMAC<SHA256> pbkdf2;
pbkdf2.DeriveKey(
derived, derived.size(),
0, // purpose byte (unused)
(const byte*)password.data(), password.size(),
salt, salt.size(),
600000 // iterations - OWASP 2023 recommendation
);
return derived;
}PBKDF2 Limitations:
- Not memory-hard (GPU attacks effective)
- Requires very high iteration counts (600,000+ for SHA-256)
- Each doubling of iterations only adds 1 bit of security
- Use Argon2 for new applications
Storage Format
Store everything needed to verify the password in the PHC string format that other Argon2 implementations read and write. It uses unpadded Base64 with the standard alphabet for the salt and the hash:
#include <cryptopp/base64.h>
#include <cryptopp/argnames.h>
#include <cryptopp/filters.h>
#include <cstdint>
#include <cstdio>
#include <sstream>
#include <stdexcept>
#include <vector>
// Unpadded Base64, standard alphabet, no line breaks: the PHC string encoding.
std::string phcBase64(const byte* data, size_t size) {
std::string out;
Base64Encoder enc(new StringSink(out), false);
enc.IsolatedInitialize(MakeParameters(Name::Pad(), false)(Name::InsertLineBreaks(), false));
enc.Put(data, size);
enc.MessageEnd();
return out;
}
SecByteBlock phcBase64Decode(const std::string& text) {
std::string bytes;
StringSource(text, true, new Base64Decoder(new StringSink(bytes)));
return SecByteBlock(reinterpret_cast<const byte*>(bytes.data()), bytes.size());
}
// $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
std::string formatForStorage(const PasswordHash& ph) {
std::ostringstream oss;
oss << "$argon2id$v=19$m=" << ph.memory << ",t=" << ph.iterations << ",p=" << ph.parallelism
<< "$" << phcBase64(ph.salt, ph.salt.size())
<< "$" << phcBase64(ph.hash, ph.hash.size());
return oss.str();
}
// Example application limits, not Argon2 limits.
// Set these to cover the largest parameters your application
// accepts, subject to available memory and concurrency.
const uint32_t MAX_MEMORY_KIB = 4194304; // 4 GiB
const uint32_t MAX_ITERATIONS = 16;
const uint32_t MAX_PARALLELISM = 16;
PasswordHash parseFromStorage(const std::string& encoded) {
// Fields: "", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash
std::vector<std::string> f;
std::string::size_type start = 0, pos;
while ((pos = encoded.find('$', start)) != std::string::npos) {
f.push_back(encoded.substr(start, pos - start));
start = pos + 1;
}
f.push_back(encoded.substr(start));
if (f.size() != 6 || !f[0].empty() || f[1] != "argon2id" || f[2] != "v=19")
throw std::runtime_error("unsupported password hash format");
PasswordHash ph;
if (f[3].size() > 40 ||
std::sscanf(f[3].c_str(), "m=%u,t=%u,p=%u", &ph.memory, &ph.iterations, &ph.parallelism) != 3)
throw std::runtime_error("bad Argon2 parameters");
std::ostringstream canonical;
canonical << "m=" << ph.memory << ",t=" << ph.iterations << ",p=" << ph.parallelism;
if (canonical.str() != f[3]) // rejects trailing text, signs, leading zeros
throw std::runtime_error("bad Argon2 parameters");
if (ph.memory > MAX_MEMORY_KIB || ph.iterations == 0 || ph.iterations > MAX_ITERATIONS ||
ph.parallelism == 0 || ph.parallelism > MAX_PARALLELISM || ph.memory < 8 * ph.parallelism)
throw std::runtime_error("Argon2 parameters outside application limits");
ph.salt = phcBase64Decode(f[4]);
ph.hash = phcBase64Decode(f[5]);
// Base64Decoder skips characters it does not recognise, so require the canonical encoding back.
if (phcBase64(ph.salt, ph.salt.size()) != f[4] || phcBase64(ph.hash, ph.hash.size()) != f[5])
throw std::runtime_error("salt or hash is not canonical unpadded Base64");
if (ph.salt.size() < 8 || ph.salt.size() > 64 || ph.hash.size() < 16 || ph.hash.size() > 64)
throw std::runtime_error("salt or hash length outside application limits");
return ph;
}
// Example output (16-byte salt, 32-byte hash):
// $argon2id$v=19$m=65536,t=3,p=4$<22 Base64 characters>$<43 Base64 characters>
The parser checks the limits before anything reaches DeriveKey; a stored record is untrusted input, and these application limits do not replace the library’s own parameter validation.
Why this format:
- Self-describing (algorithm, version, parameters)
- Can upgrade parameters without breaking existing hashes
- The PHC string format, so the same string verifies with other Argon2 implementations and tools
Compatibility with earlier releases: Argon2i and Argon2id hashes created by cryptopp-modern 2025.11.0 through 2026.9.1 may require the legacy variants. The Argon2 version field (v=19) does not distinguish these hashes from standard output. Keep a separate legacy marker or try standard verification before the corresponding legacy variant. See the migration guide.
Security Considerations
1. Constant-Time Comparison
Always use constant-time comparison to prevent timing attacks:
// WRONG - timing attack vulnerability
bool badVerify(const SecByteBlock& a, const SecByteBlock& b) {
return a == b; // Short-circuits on first difference!
}
// CORRECT - constant time comparison for equal-length buffers
bool goodVerify(const SecByteBlock& a, const SecByteBlock& b) {
if (a.size() != b.size()) {
return false;
}
return VerifyBufsEqual(a, b, a.size());
}2. Salt Requirements
- Unique per password - Never reuse salts
- Random - Use cryptographic RNG
- Sufficient length - 16 bytes minimum
- Stored with hash - Salt is not secret
// WRONG - same salt for everyone
static byte globalSalt[16] = {...}; // Rainbow table attack!
// WRONG - predictable salt
std::string salt = username; // Attackers can precompute
// CORRECT - random per password
AutoSeededRandomPool rng;
SecByteBlock salt(16);
rng.GenerateBlock(salt, salt.size());3. Pepper (Optional Extra Security)
A pepper is a secret key stored separately from the database:
// Pepper: secret key NOT stored in database
// Store in environment variable, HSM, or secure config
SecByteBlock pepper = getSecretPepper(); // 32 bytes
// Combine password with pepper before hashing
HMAC<SHA256> hmac(pepper, pepper.size());
SecByteBlock pepperedPassword(SHA256::DIGESTSIZE);
hmac.CalculateDigest(pepperedPassword,
(const byte*)password.data(), password.size());
// Then hash the peppered password with Argon2
argon2.DeriveKey(hash, hashLen,
pepperedPassword, pepperedPassword.size(),
salt, saltLen, ...);Pepper benefits:
- Database breach alone isn’t enough
- Attacker needs both database AND pepper
- Can rotate the pepper by re-hashing on next login; the old pepper must stay available for verification until every hash has been migrated
4. Password Requirements
Enforce reasonable password policies:
bool validatePassword(const std::string& password) {
// Minimum length (NIST recommends 8+)
if (password.length() < 8) return false;
// Maximum length (prevent DoS)
if (password.length() > 128) return false;
// Check against common passwords (have-i-been-pwned API)
if (isCommonPassword(password)) return false;
return true;
}Modern recommendations (NIST SP 800-63B):
- Minimum 8 characters
- No complexity requirements (they don’t help)
- Check against breached password lists
- Allow paste (password managers!)
- No periodic forced changes
Upgrading Hashes
As hardware improves, upgrade parameters on user login:
bool loginAndUpgrade(const std::string& username,
const std::string& password) {
PasswordHash stored = loadFromDatabase(username);
if (!verifyPassword(password, stored)) {
return false; // Wrong password
}
// Check if hash needs upgrading
if (stored.memory < CURRENT_MEMORY_TARGET ||
stored.iterations < CURRENT_ITERATION_TARGET) {
// Re-hash with stronger parameters
PasswordHash upgraded = hashPassword(password);
saveToDatabase(username, upgraded);
logSecurityEvent("Password hash upgraded for " + username);
}
return true;
}This example upgrades the cost parameters of standard Argon2id hashes. Hashes created by cryptopp-modern 2025.11.0 through 2026.9.1 may need legacy verification before they can be upgraded. See Migrating Argon2 Hashes from Earlier Releases for the migration flow.
Complete Example: User Registration & Login
#include <cryptopp/argon2.h>
#include <cryptopp/osrng.h>
#include <cryptopp/hex.h>
#include <cryptopp/secblock.h>
#include <iostream>
#include <string>
#include <map>
#include <cstring>
using namespace CryptoPP;
// Simulated user database
struct UserRecord {
SecByteBlock salt;
SecByteBlock hash;
uint32_t iterations = 3;
uint32_t memory = 65536;
uint32_t parallelism = 4;
};
std::map<std::string, UserRecord> database;
bool registerUser(const std::string& username,
const std::string& password) {
// Check if user exists
if (database.find(username) != database.end()) {
std::cerr << "User already exists" << std::endl;
return false;
}
// Validate password
if (password.length() < 8) {
std::cerr << "Password too short" << std::endl;
return false;
}
AutoSeededRandomPool rng;
UserRecord user;
// Generate salt
user.salt.resize(16);
rng.GenerateBlock(user.salt, user.salt.size());
// Hash password
user.hash.resize(32);
Argon2 argon2;
argon2.DeriveKey(
user.hash, user.hash.size(),
(const byte*)password.data(), password.size(),
user.salt, user.salt.size(),
user.iterations, user.memory, user.parallelism
);
database[username] = user;
std::cout << "User registered: " << username << std::endl;
return true;
}
bool loginUser(const std::string& username,
const std::string& password) {
// Find user
auto it = database.find(username);
if (it == database.end()) {
// Don't reveal whether user exists!
// Still do a hash so a missing user does not return noticeably faster
SecByteBlock dummy(32), dummySalt(16);
std::memset(dummySalt.BytePtr(), 0, dummySalt.size());
Argon2 argon2;
argon2.DeriveKey(dummy, dummy.size(),
(const byte*)password.data(), password.size(),
dummySalt, dummySalt.size(), 3, 65536, 4);
return false;
}
const UserRecord& user = it->second;
// Verify password
SecByteBlock computed(user.hash.size());
Argon2 argon2;
argon2.DeriveKey(
computed, computed.size(),
(const byte*)password.data(), password.size(),
user.salt, user.salt.size(),
user.iterations, user.memory, user.parallelism
);
// Constant-time comparison
if (!VerifyBufsEqual(computed, user.hash, user.hash.size())) {
return false;
}
std::cout << "Login successful: " << username << std::endl;
return true;
}
int main() {
// Register users
registerUser("alice", "secure_password_123");
registerUser("bob", "another_secure_pwd!");
// Login attempts
loginUser("alice", "secure_password_123"); // Success
loginUser("alice", "wrong_password"); // Fail
loginUser("eve", "doesnt_exist"); // Fail (user doesn't exist)
return 0;
}Common Mistakes
Mistake 1: Using Fast Hashes
// WRONG - SHA-256 is too fast!
SHA256 hash;
hash.Update((byte*)password.data(), password.size());
hash.Update(salt, saltLen);
hash.Final(digest); // Billions of attempts per second possible
Mistake 2: Reusing Salts
// WRONG - same salt for all users
const byte GLOBAL_SALT[] = "my_application_salt";
// Attacker can build one rainbow table for all users
Mistake 3: Non-Constant-Time Comparison
// WRONG - leaks timing information
if (computedHash == storedHash) { // Timing attack!
return true;
}Mistake 4: Insufficient Parameters
// WRONG - parameters too low
argon2.DeriveKey(hash, 32, password, pwLen, salt, 16,
1, // 1 iteration - way too low!
1024, // 1 MB - way too low!
1);Mistake 5: Truncating Password
// WRONG - limiting password length incorrectly
std::string truncated = password.substr(0, 20); // Reduces entropy!
// CORRECT - allow long passwords, Argon2 handles them
// Just set a reasonable max (128-256 chars) to prevent DoS
Performance Comparison
Approximate hashing times with recommended parameters:
| Algorithm | Parameters | Time | GPU Resistance |
|---|---|---|---|
| Argon2id | 64MB, t=3, p=4 | ~300ms | Excellent |
| scrypt | N=2^15, r=8, p=1 | ~250ms | Good |
| bcrypt | cost=12 | ~250ms | Moderate |
| PBKDF2-SHA256 | 600,000 iterations | ~200ms | Poor |
| SHA-256 | 1 iteration | ~0.001ms | None |
See Also
- Argon2 API Reference - Detailed Argon2 documentation
- scrypt API Reference - scrypt documentation
- PBKDF2 API Reference - Legacy PBKDF2 documentation
- Security Concepts - Constant-time operations, secure memory
- AutoSeededRandomPool - Generating random salts