Blog
35 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutorials

Salt, Pepper, Bcrypt and Argon2id: How to Actually Protect Passwords

In 2012, LinkedIn exposed 117M passwords. SHA-1 without salt — 90% cracked in 4 hours. Understand what each protection layer solves and why Argon2id is the right choice today.

COVER · Tutorials

In June 2012, LinkedIn was breached. 117 million passwords leaked. The protection in place was SHA-1 without salt — and 90% of passwords were cracked in under four hours. The problem wasn't the database breach itself: it was that the chosen protection made the attack trivial. Modern GPUs test billions of SHA hashes per second.

This post covers what actually protects stored passwords: salt, pepper, bcrypt, and Argon2id. Each layer solves a different problem.


Why SHA-256 isn't enough for passwords

SHA-256 is fast. Extremely fast. An RTX 4090 GPU can compute around 22 billion SHA-256 hashes per second.

That's great for file integrity checks. For passwords, it's catastrophic.

The problem: an attacker who obtains a hashed database can test combinations at industrial speed. An 8-character alphanumeric password has just over 218 trillion combinations. At 22 billion hashes per second, that's about 2.7 hours of pure brute force — and with dictionaries and mutation rules, most real-world passwords fall in minutes.

Speed is the wrong property for a password hashing function.


What salt is and what problem it solves

Salt is a random value generated per password, stored alongside the hash in the database.

Before salt, an attacker could use rainbow tables — precomputed tables mapping known hashes to original passwords. You take the hash 5f4dcc3b5aa765d61d8327deb882cf99 and look it up in the table: password. No additional computation required.

With salt, that doesn't work. If each password has a unique 128-bit salt, the attacker would need to precompute a separate rainbow table for each possible salt — computationally infeasible.

original_password: "hunter2"
salt:              "x7Kp9mNqL2..."   (randomly generated)
stored hash:       SHA-256("hunter2" + "x7Kp9mNqL2...") → stored with the salt

Salt is not a secret. It lives in the database alongside the hash. The protection comes from uniqueness, not secrecy.

What salt solves: rainbow tables and shared-hash attacks (two users with the same password will have different hashes).

What salt doesn't solve: direct brute-force against weak passwords. If the password is 123456, an attacker can still test it against that specific user's salt.


What pepper is and what problem it solves

Pepper is a global secret added to the password before hashing, stored outside the database — in an environment variable, HSM, or vault.

hash = bcrypt(password + pepper, salt)

The pepper doesn't live in the database. If the database leaks, the attacker has the hashes but not the pepper — and without the pepper, they can't validate password attempts.

What pepper solves: full database compromise. Even with the dump, the hashes are useless without the external secret.

What pepper doesn't solve: if the entire server is compromised (code + database + environment), the pepper is exposed too. It's an extra layer, not an absolute guarantee.

import os
import bcrypt

PEPPER = os.environ["PASSWORD_PEPPER"]  # secret outside the database

def hash_password(plain: str) -> bytes:
    peppered = (plain + PEPPER).encode("utf-8")
    return bcrypt.hashpw(peppered, bcrypt.gensalt(rounds=12))

def verify_password(plain: str, stored_hash: bytes) -> bool:
    peppered = (plain + PEPPER).encode("utf-8")
    return bcrypt.checkpw(peppered, stored_hash)

Why bcrypt is the standard choice

Bcrypt was designed in 1999 specifically for password hashing by Niels Provos and David Mazières. The core idea: be tunable in cost.

The cost factor N makes the function execute 2ⁿ rounds of key derivation. Cost 10 = 1,024 rounds. Cost 12 = 4,096 rounds. Cost 14 = 16,384 rounds.

As hardware gets faster, you increase the cost factor — verification time goes up, but legitimate verification is rarely a latency-critical operation (it happens once per login). Brute-force, on the other hand, becomes proportionally more expensive.

$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewrHzPjBPJgTEyF2

$2b$   → algorithm version
12     → cost factor
LQv3c1yqBWVHxkd0LHAkCO → salt (22 chars, 128 bits)
Yz6TtxMQJqhN8/lewrHzPjBPJgTEyF2 → hash (31 chars)

The hash structure includes the salt — so you don't need to store it separately. The bcrypt hash is self-contained.

The 72-byte limit

Bcrypt truncates inputs at 72 bytes. A password with 73+ characters has the excess bytes ignored. In practice, this doesn't affect most users, but it's relevant if you allow very long passwords.

Common solution: SHA-256 the password before passing it to bcrypt, converting to hex (64 bytes) — within the 72-byte limit.

import hashlib
import bcrypt

def prepare_password(plain: str) -> bytes:
    # SHA-256 → hex → 64 bytes → within bcrypt's 72-byte limit
    return hashlib.sha256(plain.encode("utf-8")).hexdigest().encode("utf-8")

def hash_password(plain: str) -> bytes:
    return bcrypt.hashpw(prepare_password(plain), bcrypt.gensalt(rounds=12))

Recommended cost factor

OWASP recommends cost 12 as a minimum for production in 2025, targeting ~250ms on the server's hardware. Faster hardware justifies cost 13 or 14.

The practical rule: benchmark on the production server and choose the highest cost factor that keeps login time under 500ms.

// Node.js — cost factor benchmark
const bcrypt = require('bcryptjs');

async function benchmark() {
  const password = 'benchmark-password';
  for (let cost = 10; cost <= 16; cost++) {
    const start = Date.now();
    await bcrypt.hash(password, cost);
    const elapsed = Date.now() - start;
    console.log(`Cost ${cost}: ${elapsed}ms`);
  }
}

benchmark();

Why Argon2id is the most secure choice today

Bcrypt was designed to resist CPUs. The problem: modern ASICs and GPUs have negligible per-chip memory compared to their computational throughput. Bcrypt is CPU-intensive but not memory-intensive — which allows parallelization on specialized hardware.

Argon2id was created to be memory-hard: it requires the attacker to allocate gigabytes of RAM per attack thread. Expensive, limited RAM eliminates the advantages of specialized hardware.

Argon2id won the Password Hashing Competition in 2015 and has been OWASP's recommendation for new systems since 2023.

The three main parameters:

Parameter Meaning OWASP minimum (2025)
m (memory) KiB per operation 19,456 KiB (19 MB)
t (iterations) processing rounds 2
p (parallelism) concurrent threads 1
# Python — Argon2id with argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(
    time_cost=2,        # iterations
    memory_cost=19456,  # 19 MB
    parallelism=1,
    hash_len=32,
    salt_len=16
)

hashed = ph.hash("my-password")
# $argon2id$v=19$m=19456,t=2,p=1$...

try:
    ph.verify(hashed, "my-password")
    print("password valid")
except VerifyMismatchError:
    print("password invalid")
// Go — golang.org/x/crypto/argon2
package main

import (
    "crypto/rand"
    "encoding/base64"
    "golang.org/x/crypto/argon2"
)

func HashPassword(password string) (string, error) {
    salt := make([]byte, 16)
    rand.Read(salt)

    hash := argon2.IDKey(
        []byte(password),
        salt,
        2,      // time
        19456,  // memory (KiB)
        1,      // threads
        32,     // keyLen
    )

    encoded := base64.RawStdEncoding.EncodeToString(hash)
    return "$argon2id$" + encoded, nil
}

Argon2i, Argon2d, or Argon2id?

  • Argon2d: maximizes resistance to GPU/ASIC (data-dependent sequential memory access), but vulnerable to side-channel attacks. Don't use for passwords.
  • Argon2i: resistant to side-channel (data-independent memory access), but less resistant to GPU. Good for key derivation in adversarial contexts.
  • Argon2id: hybrid. Uses Argon2i on the first pass and Argon2d on subsequent ones. Best of both worlds. Use this one.

scrypt: the older memory-hard alternative

scrypt (Colin Percival, 2009) is also memory-hard and appears in many projects. It's the foundation of Litecoin and several key derivation schemes.

import hashlib
import os

password = b"my-password"
salt = os.urandom(16)

# N=2^15, r=8, p=1 — reasonable parameters for login
key = hashlib.scrypt(password, salt=salt, n=32768, r=8, p=1, dklen=32)

For new projects, prefer Argon2id. scrypt has a less ergonomic interface and its parameters are harder to calibrate correctly. If you're already using scrypt in production, there's no urgent reason to migrate.


PBKDF2: when you have no other option

PBKDF2 still appears in legacy systems, FIPS compliance requirements, and some languages where Argon2 has no native support. It's CPU-bound (not memory-hard), which makes it inferior to the above, but still vastly superior to non-iterated SHA.

OWASP recommends PBKDF2-HMAC-SHA256 with 600,000 iterations if you need FIPS compliance.

import hashlib
import os

password = b"my-password"
salt = os.urandom(16)

key = hashlib.pbkdf2_hmac('sha256', password, salt, iterations=600000)

Which algorithm to use — direct decision

Scenario Choice
New system, no restrictions Argon2id
FIPS compliance required PBKDF2-HMAC-SHA256 (600k iter)
Language without native Argon2 bcrypt (cost 12+)
Legacy system in production Keep current, migrate on next login

Don't migrate existing hashes in batch. The correct migration is silent: when a user authenticates successfully, re-hash the password with the new algorithm and overwrite the old hash. Within a few weeks, most active users will be migrated.


What LinkedIn got wrong — and what you learn from it

The 2012 breach wasn't a sophisticated cryptography problem. It was an algorithm choice problem.

SHA-1 without salt means:

  1. Two users with the same password have the same hash → immediate identification
  2. No computational cost for the attacker beyond iterating a list of known hashes
  3. Precomputed rainbow tables make the attack instantaneous for common passwords

The solution doesn't require advanced mathematics. It requires using the right tool: bcrypt, scrypt, or Argon2id, with automatic salt (all three include salt in the output), cost factor calibrated to production hardware.

Password security isn't about obscure algorithms. It's about being intentionally slow.


Note: the editorial content ends here. What follows is a mention of a related tool.


To generate and verify bcrypt hashes without installing anything, visualize the internal hash structure (version, cost factor, salt, hash), and calibrate the ideal cost factor with a local benchmark that runs in your browser, use the Bcrypt Generator from Quick Tools — no data leaves the browser.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil