Skip to content

Password

Ori Pekelman edited this page May 11, 2026 · 1 revision

Tep::Password

PBKDF2-SHA256 password hashing with self-describing storage. The hash format encodes the iteration count + salt + derived bytes in a single string, so verifying doesn't need a separate column for "which params were used."

Algorithm

  • PBKDF2-SHA256 with 200,000 iterations (sidekiq/devise/bcrypt default-equivalent at this rev).
  • 16-byte random salt per password.
  • 32-byte derived key.
  • All bytes base64url-encoded.

Stored format (single column, ~85 chars):

pbkdf2-sha256$200000$<salt-b64url>$<key-b64url>

API

stored = Tep::Password.hash("hunter2")
ok     = Tep::Password.verify("hunter2", stored)        # true
nope   = Tep::Password.verify("guess",   stored)        # false

Both calls are constant-time relative to the password length.

Storage

The hash string is safe to store in a TEXT column:

CREATE TABLE users (
  id           INTEGER PRIMARY KEY,
  username     TEXT UNIQUE,
  password_h   TEXT NOT NULL
);
db.prepare("INSERT INTO users (username, password_h) VALUES (?, ?)")
db.bind_str(1, params[:user])
db.bind_str(2, Tep::Password.hash(params[:password]))
db.step
db.finalize

Cookbook

Sign-up

post '/signup' do
  if params[:password].length < 8
    halt 422, "password too short"
  end
  hashed = Tep::Password.hash(params[:password])

  db = Tep::SQLite.new
  db.open(DB_PATH)
  db.prepare("INSERT INTO users (username, password_h) VALUES (?, ?)")
  db.bind_str(1, params[:user])
  db.bind_str(2, hashed)
  db.step
  db.finalize
  db.close
  redirect "/"
end

Login

post '/login' do
  db = Tep::SQLite.new
  db.open(DB_PATH)
  stored = db.first_str("SELECT password_h FROM users WHERE username = ?", params[:user])
  db.close

  if stored.length == 0
    halt 401, "no such user"
  end
  if !Tep::Password.verify(params[:password], stored)
    halt 401, "wrong password"
  end

  session["user"] = params[:user]
  redirect "/"
end

Pitfalls

  • 200k iters is a CPU cost. A single hash call takes ~80 ms on modern hardware. That's deliberate (an attacker has to spend the same per guess) but it means a hot login endpoint will saturate the worker — prefork (--workers N) is the right answer.
  • Don't truncate the stored hash. The verifier parses the full string; truncating to fit a column with VARCHAR(64) will break it. Use TEXT.
  • The iteration count is parsed from the hash. If you ever bump the default in tep itself, old hashes still verify correctly (they carry their own iter count). Old hashes don't auto-upgrade, though — re-hash on next login.
  • The salt is per-password. Two users with the same password produce different hashes. Good — defeats rainbow tables — but also means you can't check "is this password reused" by string equality on the column.

Clone this wiki locally