Salts and Why Your Hashes Are Useless Without Them
I spent about three years working on authentication systems before I stopped seeing salts as some optional nice-to-have and started treating them like the baseline requirement they actually are. Most people who read about password hashing for the first time get the concept wrong, and the people who get it wrong are the ones whose databases get taken apart in the wild. A salt is a random string of data that gets mixed into a password before the hashing function runs. That's it. It sounds simple because it is simple, but the simplicity is where most mistakes happen. You take the user's password, append or prepend a unique random value, and then feed that combined string into your hash function. The output is what you store in the database, not the password itself and not the salt alone.
What Is A Salt
The critical detail that beginners keep missing is that the salt needs to be unique per password, not per system. I've seen implementations where the entire database shared a single salt value, which is effectively the same as not using a salt at all. If every password hash is computed with the same salt, an attacker can build a single rainbow table and run it against every entry in your table simultaneously. The uniqueness matters because it forces the attacker to compute a separate table for each individual password, which is computationally impractical at any scale larger than a handful of accounts. Here's the part nobody tells you in the beginner tutorials: the salt does not need to be kept secret. It gets stored in the database right next to the hash, usually prefixed or suffixed to the same field. Its job is not confidentiality. Its job is diversity. You're making each hash computation unique so that precomputed attacks don't scale across your user base. That's the entire purpose. I ran into a specific problem a couple years ago that illustrates why the mechanics matter more than the definition. We were migrating an old system that used MD5 with no salt, and the new system was supposed to use bcrypt. The migration script hashed each plaintext password with a newly generated salt and then compared it against the old MD5 hash to verify correctness. About 0.3 percent of users had passwords that included the null byte character. The migration was silently corrupting their accounts because the old legacy codebase was treating those passwords as fixed-length 8-character strings and truncating anything past the null terminator. The bcrypt hash was correct. The verification was failing. We ended up writing a custom import parser that read the raw bytes from the old database instead of going through the application layer's password handling, and that caught maybe forty or fifty affected accounts before they got locked out permanently. Not a great look.
So when we talk about what is a salt in practice, it's a cryptographically random value that you generate once per user account and store alongside the resulting hash. Modern systems typically use salts between 16 and 32 bytes. Anything shorter than 16 bytes starts to feel risky because the collision space shrinks. Anything longer than 64 bytes is just wasting storage for marginal security gain. The hashing algorithm you pair with the salt matters more than the salt length itself. PBKDF2, bcrypt, scrypt, and Argon2id are the standards worth using. Each one handles salting differently under the hood. bcrypt embeds the salt directly into its output string. Argon2id expects you to pass the salt separately. PBKDF2 also takes the salt as a distinct parameter. The API differences are annoying but they exist for good reason. Don't try to roll your own key derivation scheme because the bookkeeping will eat you alive and you'll get subtle implementation bugs that are nearly impossible to detect after the fact. There's a common misconception that using a longer salt compensates for a weak hashing algorithm. It doesn't. A 64-byte salt on MD5 still gives you an MD5 hash, and MD5 is broken for this purpose regardless of how random or long your salt is. The salt solves the rainbow table and bulk-computation problem. It does not solve the problem of using an algorithm that hashes too quickly or has known collision vulnerabilities. Those are separate concerns that require choosing the right function in the first place.
Get the Full Details

Another thing people get wrong is reusing salts across similar systems. If you run a mobile app backend and a web backend and they share the same salt value, a breach of either system reduces the effective uniqueness of your salting strategy. Keep the salt scoped to the specific system and the specific user. Regenerate it only when you're doing a full hash rework, not as part of your normal login flow. The one real limitation of salting is that it does absolutely nothing against online guessing attacks. If someone is brute-forcing passwords through your login endpoint, the salt is irrelevant. The attacker is making trial submissions one at a time, and your rate limiting and account lockout policies are what protect you there. Salts protect you against offline attacks where the attacker has stolen your database and is hashing candidate passwords in parallel. That's a different threat model with a different set of defenses. If you're starting a new project and need a reference implementation, the Argon2id specification from the Password Hashing Competition is the current recommendation. It handles the salt internally through its parameter structure and has built-in protections against GPU-accelerated attacks. The Go and Python standard libraries both have production-ready bindings. Don't use a third-party package that you can't audit. Don't use an implementation that hasn't been through independent review. The salt is only as good as the code that generates and verifies it.
I've also seen people try to derive the salt from user-specific data like email addresses or user IDs, thinking it adds another layer of uniqueness. It doesn't. A salt derived from predictable input is no longer random, and predictability is exactly what you're trying to avoid. Generate it with a proper CSPRNG, store it, move on.