Password hashing done right means using a slow, adaptive, memory-hard algorithm — bcrypt, scrypt, or Argon2id — never a fast general-purpose hash like MD5 or SHA-256. When an attacker steals your database, the work factor is the only thing standing between your users and mass account takeover. Indian developers building under the DPDP Act 2023 are legally required to implement "reasonable security safeguards" — and storing passwords in plaintext or MD5 is neither reasonable nor defensible. This guide explains the correct approach, the right parameters, and how to migrate legacy systems safely.
Why Plaintext and Fast Hashes Are Catastrophic
In 2016, LinkedIn confirmed that 117 million passwords stored as unsalted SHA-1 hashes had been compromised in a 2012 breach and were cracked once the data surfaced publicly. In 2009, RockYou had already demonstrated what happens with plaintext storage: 32 million credentials, fully readable. These are not edge cases — they are the industry's recurring lesson.
Fast hashing algorithms — MD5, SHA-1, SHA-256, SHA-512 — were designed for data integrity verification: checksums, digital signatures, certificate fingerprints. They are engineered to be as fast as possible. A modern GPU can compute billions of SHA-256 hashes per second. An attacker who steals your user table can run an offline dictionary attack against every account simultaneously, at no cost to your detection systems, at the speed of their hardware.
Three concrete failure modes:
- Plaintext storage: breach equals instant access to every account.
- Unsalted fast hash: a rainbow table (precomputed hash-to-password mapping) reverses millions of common passwords in milliseconds.
- Salted fast hash (SHA-256 + random salt): eliminates rainbow tables but does nothing to slow GPU brute-force. With 10 billion guesses per second, an 8-character password is cracked in under an hour.
Cryptographic Fundamentals: Salt, Pepper, and Work Factor
Salt is a random value generated per-user and stored alongside the hash. Its job is to ensure that two users with identical passwords produce different hash outputs, defeating rainbow tables and preventing batch cracking. Salts are not secret — they live in the database. Their value is uniqueness, not secrecy.
Pepper is a secret value stored outside the database — in an environment variable, a secrets manager, or an HSM. It is added to the input before hashing. If an attacker steals the database but not the pepper, they cannot crack any hash at all. Pepper is optional but recommended as defence-in-depth, particularly for regulated environments.
Work factor (also called cost factor, iteration count, or rounds depending on the algorithm) is the tunable parameter that makes password hashing adaptive. Increase it each year as hardware improves. The goal: each hash computation should take 100–500 milliseconds on your server under normal login load, while being parallelisable at scale only on expensive hardware.
bcrypt: The Proven Workhorse
bcrypt was published by Niels Provos and David Mazières in 1999 and remains widely deployed and well-understood. It is Blowfish-based and includes a built-in salt. Its cost factor is expressed as a log-2 exponent: cost 12 means 2^12 = 4,096 internal rounds.
OWASP recommended minimum for 2024: cost factor 10 on modern hardware, with 12 preferred for new systems. Benchmark on your target hardware to confirm the hash completes in 100–300 ms.
Critical limitation: bcrypt silently truncates input at 72 bytes. A password of 73 characters is treated identically to one of 72 characters. For most users this is inconsequential, but long passphrase users or applications that pre-hash input before passing to bcrypt must account for this. Never SHA-256 the password before bcrypt — this reintroduces the fast-hash problem for short outputs; use a proper pre-hashing scheme documented in the OWASP Password Storage Cheat Sheet.
Python (passlib — wraps bcrypt correctly):
from passlib.hash import bcrypt
hashed = bcrypt.using(rounds=12).hash(password)
verified = bcrypt.verify(password, hashed)Node.js (bcryptjs — pure JS, or bcrypt native):
import bcrypt from 'bcryptjs';
const hashed = await bcrypt.hash(password, 12);
const match = await bcrypt.compare(password, hashed);Know your vulnerabilities before attackers do
Run a free VAPT scan — takes 5 minutes, no signup required.
Book Your Free Scanscrypt: Memory-Hard Hashing
scrypt (Colin Percival, 2009) extended the adaptive-hash model with a memory-hard property: cracking requires not just CPU cycles but significant RAM. This directly raises the cost of ASIC and GPU attacks, which can be highly parallelised on CPU but face memory bandwidth limits.
Parameters: N (CPU/memory cost, power of 2), r (block size), p (parallelisation factor).
OWASP recommendation: N=32768 (2^15), r=8, p=1 as minimum; N=65536 for higher-assurance systems.
scrypt suits workloads needing stronger GPU resistance than bcrypt when infrastructure can support the memory overhead (32–64 MB per hash at recommended settings).
Argon2: The OWASP and NIST Recommended Standard
Argon2 won the Password Hashing Competition in 2015 and is the current recommendation from both OWASP and NIST SP 800-63B. It comes in three variants:
- Argon2d: optimised against GPU cracking, but vulnerable to side-channel attacks. Not for password hashing.
- Argon2i: side-channel resistant, used for key derivation.
- Argon2id: hybrid — first pass uses Argon2i (side-channel safe), subsequent passes use Argon2d (GPU resistant). Use this for password hashing.
| Parameter | Minimum | Recommended |
|---|---|---|
Memory (m) | 19 MB (19456 KiB) | 64 MB (65536 KiB) |
Iterations (t) | 2 | 3 |
Parallelism (p) | 1 | 4 (match CPU cores) |
| Output length | 32 bytes | 32 bytes |
| Salt length | 16 bytes | 16 bytes |
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
hashed = ph.hash(password)
ph.verify(hashed, password) # raises VerifyMismatchError on failure// Node.js — @node-rs/argon2 (native binding, recommended)
import { hash, verify } from '@node-rs/argon2';
const hashed = await hash(password, {
memoryCost: 65536, timeCost: 3, parallelism: 4
});
const match = await verify(hashed, password);Login Flow and Offline Attack Resistance
graph TD
A[User submits password] --> B[Fetch salt from DB record]
B --> C[Compute Argon2id hash
with stored params]
C --> D{Constant-time
comparison}
D -->|Match| E[Login success
Check rehash needed]
D -->|No match| F[Login failed
Increment rate-limit counter]
E --> G{Params outdated?}
G -->|Yes| H[Rehash with new params
Update DB record]
G -->|No| I[Session issued]
A2[Attacker obtains stolen DB] --> B2[Offline cracking attempt]
B2 --> C2[Each guess requires
full Argon2id computation]
C2 --> D2[64 MB RAM consumed
per guess attempt]
D2 --> E2[Cracking 1 account
takes hours on GPU]
E2 --> F2[Cracking millions
becomes economically infeasible]
style A fill:#1e3a5f,stroke:#3B82F6,color:#e2e8f0
style B fill:#1e3a5f,stroke:#3B82F6,color:#e2e8f0
style C fill:#1e3a5f,stroke:#3B82F6,color:#e2e8f0
style D fill:#1e3a5f,stroke:#3B82F6,color:#e2e8f0
style E fill:#1e3d2f,stroke:#10B981,color:#e2e8f0
style F fill:#5f1e1e,stroke:#EF4444,color:#e2e8f0
style G fill:#1e3a5f,stroke:#3B82F6,color:#e2e8f0
style H fill:#1e3a5f,stroke:#3B82F6,color:#e2e8f0
style I fill:#1e3d2f,stroke:#10B981,color:#e2e8f0
style A2 fill:#5f1e1e,stroke:#EF4444,color:#e2e8f0
style B2 fill:#5f1e1e,stroke:#EF4444,color:#e2e8f0
style C2 fill:#5f1e1e,stroke:#EF4444,color:#e2e8f0
style D2 fill:#5f1e1e,stroke:#EF4444,color:#e2e8f0
style E2 fill:#5f1e1e,stroke:#EF4444,color:#e2e8f0
style F2 fill:#1e3d2f,stroke:#10B981,color:#e2e8f0Relative Cracking Resistance Across Algorithms
pie title Relative Offline Cracking Resistance — Qualitative
"MD5 unsalted" : 1
"SHA-256 salted" : 5
"bcrypt cost-12" : 40
"Argon2id recommended" : 54The proportions above are illustrative of relative ordering, not empirical hash-rate measurements. MD5 and SHA-256 are orders of magnitude faster than bcrypt; Argon2id at recommended parameters adds memory-hardness on top of time cost, making it the strongest of the four for offline-cracking resistance.
Migrating Legacy Password Hashes
Running MD5 or SHA-1 hashes in production is common in Indian startups that inherited older codebases. Migration does not require forcing all users to reset their passwords simultaneously — an online migration is safer:
- Add a
hash_algorithmcolumn to your users table (values:md5_legacy,sha1_legacy,bcrypt_v1,argon2id_v1). - On each successful login, verify the password against the legacy hash. If it matches, immediately rehash with Argon2id and update the record.
- After 90 days, any account that has not logged in still holds a legacy hash. At this point you have three choices: force a password reset on next login, email a reset prompt, or accept that inactive accounts remain legacy until they return.
- Never store the plaintext during migration. The plaintext is only available at the login moment — use it and discard it.
Argon2id(MD5(password)) is not a valid migration — it simply applies Argon2id to a fast hash of unknown character, and an attacker who knows the scheme can still exploit it. The only valid upgrade path is at login time with the original plaintext.Rate Limiting and MFA: Defence in Depth
Password hashing protects your stored credentials after a breach. Rate limiting and MFA protect the login endpoint before a breach.
| Control | What It Stops | Implementation Note |
|---|---|---|
| Progressive delay on failed logins | Online brute-force | Exponential backoff after 5 failures |
| Account lockout with unlock token | Credential stuffing | Lock after 10 failures; email unlock |
| TOTP / FIDO2 MFA | Credential reuse from other breaches | Required for admin and finance roles |
| Device fingerprinting + anomaly alert | Account takeover post-credential theft | Flag unfamiliar device on first login |
| Pepper rotation | DB-only exfiltration | Re-login triggers rehash with new pepper |
DPDP Act Compliance and "Reasonable Security Safeguards"
India's Digital Personal Data Protection Act 2023 (DPDP) requires data fiduciaries to implement reasonable security safeguards. Passwords are personal data. Storing them in a reversible or weakly hashed form is not a reasonable safeguard — it is a known-bad practice with documented attack paths.
CERT-In guidelines under the IT Act require protecting authentication credentials. A breach disclosure revealing MD5-stored passwords will draw scrutiny over whether due diligence was exercised.
Implementing Argon2id at OWASP-recommended parameters, enforcing rate limiting, and logging authentication anomalies provides a documented, defensible security posture. For a detailed assessment of your authentication implementation and the rest of your attack surface, the Bachao.AI blog covers practical security engineering for Indian developers, and you can run a free VAPT scan to identify exposed endpoints and configuration weaknesses.
Dhisattva AI Pvt Ltd builds automated VAPT tooling specifically for Indian SMBs navigating DPDP, CERT-In, and SEBI compliance requirements.
Quick Reference: Algorithm Selection
| Algorithm | Memory Hard | GPU Resistant | 72-byte Limit | OWASP Recommended | Use Case |
|---|---|---|---|---|---|
| MD5 | No | No | No | Never | Legacy only — migrate immediately |
| SHA-256 salted | No | No | No | Never | Never for passwords |
| bcrypt (cost 12) | No | Partially | Yes — 72 bytes | Yes (minimum) | Existing bcrypt systems; maintain |
| scrypt (N=65536) | Yes | Yes | No | Yes | Systems needing memory-hardness |
| Argon2id (recommended params) | Yes | Yes | No | Primary | All new systems |
External References
- OWASP Password Storage Cheat Sheet — canonical parameter recommendations, migration guidance, library references.
- NIST SP 800-63B — Digital Identity Guidelines — authentication and lifecycle requirements, memorised secret guidance, banned passwords.
Frequently Asked Questions
Why can I not use SHA-256 with a salt for password storage?
What Argon2id parameters should I use in a Node.js application?
@node-rs/argon2 package (native binding) or argon2 npm package. Benchmark on your server to confirm hashing completes in under 500 ms under expected login concurrency.