DEV Community

Cover image for The One Line of Code That Stops Rainbow Table Attacks
Bijan
Bijan

Posted on

The One Line of Code That Stops Rainbow Table Attacks

Someone steals a database of password hashes. Instead of cracking each one individually, they check it against a rainbow table — a massive precomputed list mapping hashes to the passwords that produced them. If a stolen hash shows up in that table, they're done in milliseconds. No cracking required, just a lookup.

Salting kills this attack entirely, and it's a small enough fix that it's worth understanding exactly why it works rather than just knowing "you're supposed to do it."

This is part 2 of a series where I'm documenting what I'm learning building CryptoGraphy, a small Python project I'm using to actually understand applied cryptography instead of just calling library functions.

What a salt actually does

In main.py, before any key derivation happens, a fresh random salt is generated:

import os

salt = os.urandom(16)
key = derive_key(password, salt)
Enter fullscreen mode Exit fullscreen mode

That salt gets fed into Argon2id alongside the password:

def derive_key(master_password: str, salt: bytes) -> bytes:
    return hash_secret_raw(
        secret=master_password.encode("utf-8"),
        salt=salt,
        time_cost=3,
        memory_cost=65536,
        parallelism=4,
        hash_len=32,
        type=Type.ID,
    )
Enter fullscreen mode Exit fullscreen mode

The effect: two users with the identical password get two completely different derived keys, because their salts are different. Same input password, different salt, unrelated output.

Why this specifically defeats rainbow tables

A rainbow table only works because it's precomputed once and reused against every hash in a stolen database. That only makes economic sense if the same password always produces the same hash — precompute "password123" → its hash one time, and that entry is useful forever, against any system, any user.

Add a per-user salt, and that assumption breaks. Now "password123" with salt A produces a completely different output than "password123" with salt B. An attacker would need to precompute a separate rainbow table for every possible salt value, which — with a 16-byte random salt — is computationally meaningless to attempt. You've turned one attack that scales across every victim into an attack that has to be redone, from scratch, per victim.

The salt isn't secret — and that's fine

This is the detail that trips people up: the salt in main.py isn't hidden anywhere. It's meant to sit right next to the ciphertext (or the stored hash) in plain sight, and that's by design.

The salt's job was never to be a secret. Its job is to guarantee uniqueness — that no two derivations, even of the same password, ever produce the same result. Secrecy is the key's job. Uniqueness is the salt's job. They're different properties solving different problems, and a salt doesn't stop doing its job just because an attacker can see it.

Why this is easy to skip and expensive to skip

Skipping the salt costs you nothing while you're learning — the code still runs, the demo still works, the tests still pass. It's only expensive once real data is at stake, because "storing unsalted hashes" is one of those failures that's invisible until a breach happens, at which point it turns an already-bad leak into a catastrophic one: every user with a common password becomes crackable in bulk, instantly.

The takeaway

One os.urandom(16) call, fed into key derivation, is the difference between "a leaked database is a slow, per-user cracking problem" and "a leaked database is a five-minute rainbow table lookup." It's a genuinely small piece of code carrying a genuinely large amount of the actual security guarantee.


Next in this series: why I specifically chose Argon2id over faster alternatives like PBKDF2 — and why "slow" is the entire point of a key derivation function.

Have you dealt with a real system that turned out to be storing unsalted hashes? Curious how that played out.

Top comments (0)