Skip to content
Lucas Mauro

Salt: storing passwords safely

A unique identifier that greatly improves security.

security 3 min read

A salt is a random value mixed into the input before Hashing: instead of storing hash(password), we store hash(password + salt), where salt is unique per user. Unlike the password itself, the salt is not secret; it sits next to the hash in plain sight, because its job is uniqueness, not secrecy.

As we saw on Hashing, slow algorithms such as Argon2id make each brute-force guess expensive; a salt is another step towards greater security: it makes sure none of that guessing effort can ever be shared between users.

Hashing alone leaves an attacker with two shortcuts:

  1. If hashing provides the same output for the same input, two users with the same password would be identifiable in a database (e.g. "password123" → "HLjoNW2k"), so cracking one would also crack all others;
  2. Since passwords usually come from predictable words, an attacker can precompute the hashes of millions of common candidates, such as names, dates and variations (that is called a “rainbow table”), and in this case cracking would become a query problem instead of a computation problem.

A unique salt per user removes both of these shortcuts, because now identical passwords produce completely different stored values and therefore one cracked account would never lead to another. Also, precomputed tables become worthless, because a guess can only mean anything once combined with that specific user’s salt.

UserPassword (plain)Salt (plain)Stored hash (illustrative)
alicehunter2k3x9qa7f3ac91e…
bobhunter2z8m1tpc92e4d07…
carolletmeinq5n2wr41ba8f22…

Alice and Bob type the same password, yet their stored hashes share nothing; the only difference is the random salt. Note that the plain-text password column exists purely for illustration: in practice only the salt and hash are stored, and the password is forgotten the moment it is hashed.

  • Unique per user: ideally regenerated whenever the password changes;
  • Properly random: drawn from a cryptographic source (crypto.getRandomValues(), /dev/urandom);
  • Long enough: 16+ bytes of randomness is the norm;
  • Format does not matter: even UUIDv4 works fine.

What does not work: one global salt shared by everyone (this recreates the “same input → same output” problem) or predictable choices such as usernames or user IDs, which let attackers precompute tables per common value.

In practice, bcrypt and Argon2id internally generate the salt for us, embedding it inside the stored string, alongside everything else that the verification needs:

# bcrypt:
$2b$12$k3x9qaZ8m1tpR5wQvN0uOeS9yGxJ2KcLbFhD4aE6iC7jH
│ │ └─ salt + hash (53 characters)
│ └─ cost factor (2¹² iterations)
└─ algorithm version
# argon2id:
$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaGhhc2g...
│ │ │ │ └─ hash (base64)
│ │ │ └─ salt (base64)
│ │ └─ memory (KiB), iterations, threads
│ └─ version
└─ algorithm & variant

Although looking weird to human eyes at first, these strings are actually well thought out with delimited information required for the hashing algorithms to work properly. Another great benefit is that, with everything stored in a single place, we require no further infrastructure, no further database column, or whatever.

Since verification works by recomputing hash(candidate + salt) and comparing against the stored hash, if the salt is lost somehow, we cannot determine the validity of the input. This means that, if it were a user’s password, restoration could only be done via a full password reset. This would be a contained issue: since salt is unique, messing up one would result in messing up only a single stored credential.

Comments