Password hashing

Backend Fundamentals · Backend Security · 8 min read

1. What problem does this solve?

  • Problem: Storing user passwords in plain text or using weak reversible encryption exposes every account if the database is compromised.
  • Why it matters: Attackers who obtain a dump of the user table can immediately log in as any user, and often reuse the same passwords across services.
  • Consequences of not using it:
    • Immediate credential compromise.
    • Loss of user trust and regulatory penalties (GDPR, CCPA).
    • Potential for credential stuffing attacks on other services.
  • Real‑world appearance:
    • Authentication microservices in SaaS platforms.
    • User tables in PostgreSQL, MongoDB, or Redis.
    • Password reset endpoints in Next.js API routes or FastAPI endpoints.

2. Mental model

Analogy

Think of a password as a secret recipe.

  • Hash: The recipe is turned into a unique “signature” that cannot be reversed.
  • Salt: A random ingredient added to each recipe so that even identical recipes produce different signatures.
  • Pepper: A secret ingredient known only to the kitchen (the application) that is never stored with the signature.

Engineering model

  1. Input: User’s plain‑text password.
  2. Salt generation: Random bytes unique per user.
  3. Key stretching: Apply a computationally expensive hash (bcrypt, Argon2, scrypt).
  4. Store: Save the salt, the hash, and optionally a pepper‑derived key.
  5. Verification: Re‑apply the same process to the input and compare hashes.

3. Deep explanation

TermMeaningWhy it matters
HashOne‑way function producing a fixed‑size output.Prevents reversing to the original password.
SaltRandom data added to the password before hashing.Defeats rainbow‑table attacks and ensures identical passwords hash differently.
PepperSecret key stored outside the database (e.g., in a secrets manager).Adds an extra layer of secrecy; if the DB leaks, the pepper is still protected.
Key stretchingRepeating the hash function many times or using memory‑hard algorithms.Increases the cost of brute‑force attacks.
Memory‑hardRequires a large amount of RAM per hash (e.g., Argon2id).Makes GPU/ASIC attacks expensive.

Trade‑offs

AspectFast hash (e.g., SHA‑256)Slow hash (bcrypt/Argon2)
SpeedVery fast (microseconds).Slow (milliseconds to seconds).
SecurityWeak against brute‑force.Stronger against brute‑force.
Resource usageLow CPU, low RAM.High CPU, high RAM.
ScalabilityCan handle millions of logins per second.Limited by CPU/RAM; may need dedicated workers.

Failure modes

  • Reusing salts: Same salt for multiple users → identical hashes for identical passwords.
  • Using a fast hash: Enables attackers to try billions of guesses per second.
  • Storing the hash in plain text: No protection if DB leaks.
  • Not rotating the algorithm: Stale algorithms become vulnerable over time.

Limitations

  • One‑way: You cannot recover the original password.
  • No protection against social‑engineering: Users may reuse passwords elsewhere.
  • Requires secure storage of pepper: If the pepper leaks, all hashes become vulnerable.

Where it fits in architecture

  • Auth service: Handles registration, login, password reset.
  • Database: Stores user_id, password_hash, salt, pepper_hash (if used).
  • Cache (Redis): May cache failed login attempts for rate limiting.
  • Secrets manager: Holds the pepper value.
  • Rate limiting: Throttle login attempts to reduce brute‑force impact.
  • Account lockout: After N failures, temporarily block the account.
  • Password policy enforcement: Ensure complexity before hashing.
  • Audit logs: Record password changes securely.

4. How it works

  1. User signs up

    • Receive plain‑text password.
    • Generate a 16‑byte random salt.
    • Compute hash = hash_function(password + salt + pepper).
    • Store user_id, salt, hash in DB.
  2. User logs in

    • Retrieve stored salt and hash.
    • Compute hash_candidate = hash_function(input_password + salt + pepper).
    • Compare hash_candidate with stored hash.
    • If equal → authentication success.
  3. Password reset

    • Generate a time‑limited token (JWT or random string).
    • Send token via email.
    • On reset, hash new password as in step 1.
sequenceDiagram
    participant U as User
    participant S as Auth Service
    participant D as Database
    participant SM as Secrets Manager

    U->>S: POST /signup {password}
    S->>SM: Get pepper
    S->>S: Generate salt
    S->>S: hash = hash_func(password + salt + pepper)
    S->>D: INSERT (user_id, salt, hash)
    Note over S,D: Store hash, salt

    U->>S: POST /login {password}
    S->>D: SELECT salt, hash WHERE user_id=?
    S->>SM: Get pepper
    S->>S: hash_candidate = hash_func(password + salt + pepper)
    alt hash_candidate == hash
        S->>U: 200 OK
    else
        S->>U: 401 Unauthorized
    end

5. Practical implementation

Node.js example with argon2

// src/auth/hash.ts
import argon2 from 'argon2';
import { randomBytes } from 'crypto';
import { getPepper } from '../secrets/pepper'; // fetch from secrets manager

/**
 * Hash a plain‑text password.
 * @param password - User supplied password
 * @returns {salt: string, hash: string}
 */
export async function hashPassword(password: string) {
  const salt = randomBytes(16).toString('hex'); // 128‑bit salt
  const pepper = await getPepper(); // e.g., from AWS Secrets Manager
  const hash = await argon2.hash(password + salt + pepper, {
    type: argon2.argon2id,
    memoryCost: 2 ** 16, // 64 MB
    timeCost: 3,
    parallelism: 1,
  });
  return { salt, hash };
}

/**
 * Verify a password against stored salt and hash.
 */
export async function verifyPassword(
  password: string,
  salt: string,
  storedHash: string
) {
  const pepper = await getPepper();
  const hashCandidate = await argon2.hash(password + salt + pepper, {
    type: argon2.argon2id,
    memoryCost: 2 ** 16,
    timeCost: 3,
    parallelism: 1,
  });
  return hashCandidate === storedHash;
}

Express route example

// src/routes/auth.ts
import express from 'express';
import { hashPassword, verifyPassword } from '../auth/hash';
import { getUserByEmail, createUser, updateUserPassword } from '../db/users';

const router = express.Router();

/**
 * POST /signup
 * Body: { email, password }
 */
router.post('/signup', async (req, res) => {
  const { email, password } = req.body;
  const existing = await getUserByEmail(email);
  if (existing) return res.status(409).send('Email already in use');

  const { salt, hash } = await hashPassword(password);
  await createUser(email, salt, hash);
  res.status(201).send('Account created');
});

/**
 * POST /login
 * Body: { email, password }
 */
router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await getUserByEmail(email);
  if (!user) return res.status(401).send('Invalid credentials');

  const ok = await verifyPassword(password, user.salt, user.password_hash);
  if (!ok) return res.status(401).send('Invalid credentials');

  // Issue JWT or session cookie here
  res.send('Logged in');
});

Tip: Keep the pepper out of the codebase; fetch it at runtime from a secrets manager.

Storing in PostgreSQL

CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email       TEXT UNIQUE NOT NULL,
  salt        TEXT NOT NULL,
  password_hash TEXT NOT NULL,
  created_at  TIMESTAMP WITH TIME ZONE DEFAULT now()
);

6. Production considerations

ConcernRecommendation
ScalabilityOffload hashing to a dedicated worker pool or use a microservice. Use async workers (e.g., BullMQ) to queue heavy hash jobs.
ReliabilityRetry hashing on transient failures. Use idempotent DB writes.
Security- Store pepper in a secrets manager (AWS KMS, HashiCorp Vault).
- Use Argon2id with recommended parameters (memoryCost ≥ 64 MiB, timeCost ≥ 3).
- Rotate pepper every 6–12 months.
ObservabilityLog hash algorithm version, memory/time cost. Monitor CPU/RAM usage of hashing workers.
PerformanceBenchmark hash cost; adjust timeCost/memoryCost to balance security and latency.
Error handlingReturn generic “Invalid credentials” to avoid timing attacks.
Resource usageHashing is CPU‑bound; scale horizontally.
Operational concernsEnsure secrets manager is highly available. Use environment variables for config.
Failure recoveryIf hashing service crashes, queued jobs persist.
DeploymentUse container images with pinned dependencies. Run hashing workers in separate pods/containers.

7. Common mistakes

MistakeWhy it’s problematic
Using MD5/SHA‑1 for passwordsExtremely fast → brute‑force feasible.
No salt or reused saltEnables rainbow‑table attacks; identical passwords produce identical hashes.
Storing pepper with the hashIf DB leaks, attacker has all secrets.
Hard‑coding pepper in codeExposes it if repository is compromised.
Using a single hash function for all usersIf the algorithm becomes weak, all accounts are exposed.
Not handling errors during hashingCan leak timing information or crash the service.
Ignoring password policyWeak passwords are still vulnerable even if hashed.

8. Senior engineer perspective

What changes at scale?

  • Architecture: Separate auth service, hashing workers, and secrets manager. Use message queues (Kafka, RabbitMQ) to decouple registration from hashing.
  • Trade‑offs:
    • Latency vs. cost: Higher memoryCost protects better but increases CPU/RAM.
    • Centralized vs. distributed pepper: Centralized pepper simplifies rotation but introduces a single point of failure.
  • Bottlenecks:
    • CPU‑bound hashing can saturate servers.
    • Secrets manager latency can become a choke point.
  • Failure scenarios:
    • Pepper rotation failure → new passwords cannot be verified.
    • Hashing worker crash → registration stalls.
  • Monitoring:
    • Track hashing latency, error rates, and resource usage.
    • Alert on sudden increases in failed logins (possible brute‑force).
  • Cost/performance:
    • Memory‑hard algorithms increase RAM usage; may require larger instances.
    • Offloading to GPU or specialized hardware can reduce cost if traffic is high.
  • When NOT to use:
    • Low‑security internal tools where password reuse is acceptable.
    • Passwordless authentication (OAuth, magic links) where passwords are never stored.

9. Interview questions

#QuestionAnswer points
1Beginner: What is a salt and why do we use it?• Random data added to password before hashing.
• Prevents identical passwords from producing identical hashes.
• Defeats rainbow‑table attacks.
2Intermediate: Explain the difference between a hash function and a key derivation function (KDF).• Hash: one‑way, fixed output, fast.
• KDF: intentionally slow, configurable cost, designed for passwords.
• Examples: SHA‑256 vs. bcrypt/Argon2.
3Senior: How would you design a password hashing service that can handle millions of registrations per day?• Use a dedicated worker pool or microservice.
• Queue hashing jobs (e.g., BullMQ).
• Scale workers horizontally.
• Use efficient KDF (Argon2id) with tuned parameters.
• Store pepper in a secrets manager.
4Senior: What are the security implications of rotating the pepper value?• Requires re‑hashing all existing passwords.
• Must maintain backward compatibility (store algorithm version).
• Use migration scripts or incremental rehash on login.
5Scenario: A user reports that they cannot log in after a password reset. The system logs show the hash comparison failed. What could be wrong?• Password input mismatch (typo).
• Salt or pepper corrupted or missing.
• Hash algorithm version mismatch (e.g., old bcrypt vs. new Argon2).
• Database row truncated or corrupted.
• Verify by re‑hashing the reset password with stored salt and pepper.

10. Hands‑on task

Build a “Password Reset” micro‑service

  1. Create a Next.js API route /api/auth/reset that accepts { email, newPassword }.
  2. Generate a time‑limited JWT token (expires in 15 min) and send it to the user’s email (mock the email).
  3. Create a second route /api/auth/reset/confirm that accepts { token, newPassword }.
  4. Verify the token, hash the new password using Argon2, and update the user record.
  5. Log the password reset event with timestamp and user ID.
  6. Add unit tests for hashing, verification, and token expiration.

Goal: Demonstrate end‑to‑end password hashing, secure storage, and proper error handling.

11. Quick revision

  • Passwords must never be stored in plain text.
  • Use a salt (unique per user) to defeat rainbow tables.
  • Use a pepper (kept outside the DB) for extra secrecy.
  • Prefer memory‑hard KDFs (Argon2id, scrypt) over fast hashes.
  • Store algorithm version, salt, and hash; never store the pepper.
  • Verify by re‑hashing input + salt + pepper and comparing.
  • Rotate pepper periodically; re‑hash on login if needed.
  • Offload hashing to workers for high traffic.
  • Monitor hashing latency, CPU/RAM usage, and failed login rates.
  • Avoid reusing salts, hard‑coding secrets, or using weak algorithms.

12. References