All posts

Where should passwords be stored to stay safe? Hashes, salts, and slow hashes

This post explains safe password storage using the analogies of locks and salt. It covers why hashes, salts, and slow hashing algorithms like bcrypt and Argon2 are essential, with examples.

Contents

    Think of a locker at a library. It's convenient for the manager to hang copies of every locker key on the wall, but if a thief gets in once, every locker opens. Storing passwords in plain text is exactly this situation. So what if you kept only a "key-shaped trace" instead of the key itself? The server doesn't remember the password itself, only a fingerprint made from it. This fingerprint is a hash. This post covers how to make that fingerprint, and why it must be made "slow."

    Hash: a unique identifier from which the data cannot be recovered.

    A hash function converts an arbitrary input into a string of fixed length. It always produces the same result for the same input, and it is practically impossible to work backward from the output to the original data. Unlike encryption, there is no decryption key. That is its defining feature.

    The login process is simplified. At sign-up, the input is hashed and stored. At login, the attempt is hashed the same way and compared against the stored value.

    flowchart LR
        A[가입: 비밀번호 입력] --> B[해시 계산]
        B --> C[(DB에는 해시만 저장)]
        D[로그인: 비밀번호 입력] --> E[해시 계산]
        E --> F{저장된 해시와 같은가}
        C --> F
        F -->|같음| G[로그인 성공]
        F -->|다름| H[로그인 실패]

    With this approach, even if the database leaks, the original passwords are not immediately exposed. But if that were the end of the story, there would be nothing more to discuss. Hashing alone still leaves two weaknesses.

    Hole 1. The same password produces the same hash

    A hash algorithm always produces the same output for the same input, and attackers can exploit this. They hash many common passwords in advance, build a database of the results, and compare it against leaked data. This is called a rainbow table attack.

    Also, users who share the same password end up with identical hash values in the database. So if one account is compromised, other accounts with the same hash can be hit at the same time.

    Salt: a different seasoning for each user

    The solution is to use a salt. A salt is a random string generated separately for each user and combined with the password before hashing. It works like adding a different seasoning to each dish, so every result (hash value) turns out different.

    flowchart LR
        P[비밀번호] --> M[섞기]
        S[사용자별 무작위 솔트] --> M
        M --> H[해시 계산]
        H --> R[솔트 + 해시를 함께 저장]

    A salt is not a secret. You can store it right next to the hash. The goal is not to hide it but to make precomputed tables useless. If the salt differs for each user, an attacker has to start the calculation from scratch for every account.

    Let's look at a simple example. Below is an implementation of salted hashing in Python. Since this is for learning purposes, in a real service please use the dedicated library covered later.

    import hashlib, os
    
    def hash_with_salt(password: str):
        salt = os.urandom(16)  # 사용자마다 새로 만드는 16바이트 무작위 값
        digest = hashlib.sha256(salt + password.encode()).hexdigest()
        return salt.hex(), digest
    
    print(hash_with_salt("hello1234"))
    print(hash_with_salt("hello1234"))

    The same password gives two completely different outputs. That's because the salt changes every time. So far, that's confirmed.

    Hole 2. Hashes are too fast

    General-purpose hash functions like SHA-256 are designed for speed. That's efficient for verifying file integrity, but it's a weakness for password security. If the database leaks, attackers can run unlimited offline brute-force attacks using their computing resources. Given that GPUs can perform billions of operations per second, simple or common passwords can be broken quickly.

    OWASP also makes this point clear. It explains that fast hash algorithms like SHA-256 are unsuitable for password storage because attackers can run massive numbers of guesses. The best defenders can do is choose a hash algorithm that uses as many resources as practical, slowing down offline attacks.

    Let's flip the approach here. The 0.1 seconds it takes for a regular user to compute one hash at login is barely noticeable. Attackers, however, need billions of computations, so a delay of 0.1 seconds per attempt becomes a crippling cost for them.

    Slow hash functions: deliberately designed to run slowly

    A slow hash (Slow Hash) is a dedicated hash function designed so its computational cost can be adjusted flexibly. Representative algorithms are bcrypt and Argon2. According to current industry standard recommendations, OWASP guides the use of strong slow hash algorithms such as Argon2id, bcrypt, and PBKDF2.

    AlgorithmHow the cost is increasedCharacteristics
    bcryptWork factorA long-proven method. Has an input length limit (72 bytes)
    scryptCPU and memory costDesigned to use a lot of memory
    Argon2idMemory, iteration count, parallelismWinner-class of the 2015 hashing competition. First choice for new services
    PBKDF2Iteration countChoose it when FIPS-140 certification is required

    What makes Argon2id special is that it makes you use a lot of memory. Even if an attacker runs thousands of GPU cores, each core needs its own memory, so the advantage of parallelization shrinks. The design reduces the efficiency of password-cracking hardware.

    How should you set the values?

    OWASP's minimum Argon2id settings are 19 MiB of memory, 2 iterations, and parallelism of 1. As an alternative offering similar protection, combinations such as m=47104 (46 MiB), t=1, p=1 are also suggested. For legacy environments using bcrypt, OWASP advises a work factor of 10 or higher and respecting the 72-byte password length limit.

    These figures are baselines. OWASP also recommends benchmarking on your actual server environment when setting security parameters. If slower logins overload the server, it can end up causing a denial-of-service problem.

    A small example, let's check it ourselves

    In a real service, don't assemble hash functions yourself. Use a vetted library. Here is an example with Python's argon2-cffi.

    from argon2 import PasswordHasher
    
    ph = PasswordHasher()  # 기본값이 안전하게 잡혀 있음
    
    stored = ph.hash("hello1234")
    print(stored)
    # $argon2id$v=19$m=...,t=...,p=...$솔트$해시
    
    ph.verify(stored, "hello1234")   # 맞으면 True
    # 틀리면 VerifyMismatchError 발생

    A single output string contains the algorithm, version, cost settings, salt, and hash all together. You don't need to store or manage the salt separately. The library creates and extracts them automatically during verification.

    Cost settings need to be raised periodically. Since the only point where the user provides the original password is at login, the common approach is to rehash with the new settings upon successful login and save it. This lets you keep operating without forcing users to reset their passwords.

    The whole picture at once

    Here is the flow so far, grouped into sign-up and login.

    flowchart TD
        A[비밀번호 입력] --> B[라이브러리가 무작위 솔트 생성]
        B --> C[느린 해시 계산: Argon2id 또는 bcrypt]
        C --> D[(솔트 + 비용 설정 + 해시 저장)]
        D --> E[로그인 시 같은 설정으로 다시 계산]
        E --> F{일치?}
        F -->|예| G[성공 + 필요하면 새 설정으로 재해시]
        F -->|아니오| H[실패]

    To summarize, each piece has its role. Hashing hides the original, salts prevent precomputed tables and shared exposure, and slow hashes raise the cost of mass guessing. If any one of the three is missing, a hole remains.

    One more note. No matter how thorough your security measures are, a simple password like '1234' can be stolen quickly. Storage methods are only a after-the-fact safeguard that minimizes damage in a leak, so encouraging users to set complex, unique passwords is also an important role for the service. Storage is the last line of defense, and I will cover the preventive measures that should come first in a follow-up post.

    Shouldn't I just encrypt passwords for storage?

    Encryption can be reversed back to the original if you have the key. If the server or key is breached, every password is exposed at once. The server doesn't need to know the original, so an irreversible hash is the right choice.

    What if I'm already storing them with SHA-256 or MD5?

    It's best to migrate to a new method. Since you don't know the originals, converting everything at once is hard. The practical approach is to rehash with the new algorithm when a user logs in successfully and replace the stored value. During the transition, prefix the hash with an algorithm marker to tell them apart.

    Should the salt be kept secret?

    No. You can store the salt together with the hash. The key point is that even if an attacker knows the salt, they can't reuse precomputed tables because it differs for each user. An extra secret value kept separately is called a pepper, and it is only a supplementary measure.

    Comments 0

    1. Loading comments.