Skip to content
Password Hashing Best Practices

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

  1. Attacker steals your user database
  2. Database contains password hashes
  3. Attacker runs offline brute-force attack
  4. No rate limiting, no account lockouts - just raw computing power

Your defense: Make each password guess computationally expensive.

Algorithm Comparison

AlgorithmMemory-HardRecommendedNotes
Argon2id✅ Yes✅ Best choiceRFC 9106, PHC winner
scrypt✅ Yes✅ GoodRFC 7914, proven
bcrypt⚠️ Limited⚠️ Acceptable72-byte password limit
PBKDF2❌ No⚠️ Legacy onlyFIPS compliant, GPU-friendly
SHA-256❌ No❌ NeverToo fast
MD5❌ No❌ NeverBroken 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

ParameterMeaningTypical Value
NCPU/memory cost (power of 2)2^14 to 2^20
rBlock size8
pParallelization1

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:

AlgorithmParametersTimeGPU Resistance
Argon2id64MB, t=3, p=4~300msExcellent
scryptN=2^15, r=8, p=1~250msGood
bcryptcost=12~250msModerate
PBKDF2-SHA256600,000 iterations~200msPoor
SHA-2561 iteration~0.001msNone

See Also