guest@websec: ~/websec/05-authentication — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % man 05-authentication

05 · Authentication

Authentication asks one thing: are you really you? Get it right and a stolen database is useless, login leaks nothing, and password reset isn't a back door.

bin/rails generate authentication (Rails 8) gives you hashed passwords, DB-backed sessions, rate-limited login and safe reset links. You still add: minimum length, breached-password checks, MFA.

5.1 Never store the password itself

Databases leak. When they do, plain passwords leak too, and people reuse them everywhere. So we store a hash: a one-way scramble. At login, hash what they typed and compare.

But not just any hash. MD5 and SHA-256 are fast, so a GPU guesses billions per second. Password hashes are deliberately slow and salted:

  • Slow: bcrypt or Argon2 make every guess cost milliseconds.
  • Salted: same password, different hash per user. Precomputed tables stop working.
sim: what a database thief seesruns real hashes in your browser

Two people, same password. See what each storage method gives a thief.

strategyalicebobverdict
# Gemfile: gem "bcrypt"
class User < ApplicationRecord
  has_secure_password                       # bcrypt hash in password_digest
  validates :password, length: { minimum: 12 }, allow_nil: true   # you add this
end

User.create!(email_address: "ana@x.test", password: "correct horse battery staple")
user.authenticate("typed password")         # => user, or false
// npm install bcrypt   (or argon2)
const bcrypt = require("bcrypt");

// sign-up: store only the hash (salt is embedded in it)
const hash = await bcrypt.hash(password, 12);   // 12 = cost factor
await db.users.insert({ email, password_hash: hash });

// login
const ok = await bcrypt.compare(typedPassword, user.password_hash);
# pip install argon2-cffi   (Django: make_password / check_password do this for you)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()

# sign-up: store only the hash (salt is embedded in it)
user.password_hash = ph.hash(password)

# login
try:
    ph.verify(user.password_hash, typed_password)
except VerifyMismatchError:
    deny()

Password rules the library won't add for you

Heads up: bcrypt only looks at the first 72 bytes of a password, and nothing enforces a minimum length. Go for length over complexity: at least 12 characters (NIST now says 15 when the password is the only factor), allow long passphrases, and block passwords that showed up in known leaks.

sim: password policy checkertype a password
has_secure_password gives you bcrypt and rejects passwords over 72 bytes. You add validates :password, length: { minimum: 12 }, and maybe the pwned gem.

5.2 Logging in without leaking information

Never say which part was wrong. "No account with that email" tells an attacker who your users are. One message for every failure.

Timing leaks too: if unknown emails answer instantly and wrong passwords take 200 ms, the clock gives it away.

sim: user enumerationyou are the attacker

Try the attacker's email list on both forms. Which one gives secrets away?

password: wrong-guess

leaky login

generic login

attacker's notes: (empty)
class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create   # Rails 7.2+

  def create
    # authenticate_by hashes even when the email is unknown (timing-safe)
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user            # generator helper: fresh Session record
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end
end
app.post("/login", async (req, res) => {
  const user = await db.users.findOne({ email: req.body.email });
  // compare against a dummy hash when the user doesn't exist,
  // so both paths take the same time
  const hash = user ? user.password_hash : DUMMY_BCRYPT_HASH;
  const ok = await bcrypt.compare(req.body.password, hash);
  if (!user || !ok) return res.status(401).render("login",
      { error: "Invalid email or password." });       // one message
  await startNewSession(req, user);                   // see section 06
  res.redirect("/");
});
@app.post("/login")
def login():
    user = User.query.filter_by(email=request.form["email"]).first()
    # verify against a dummy hash when the user doesn't exist,
    # so both paths take the same time
    stored = user.password_hash if user else DUMMY_HASH
    ok = verify(stored, request.form["password"])
    if not user or not ok:
        return render_template("login.html",
            error="Invalid email or password."), 401   # one message
    start_new_session(user)                             # see section 06
    return redirect("/")
User.authenticate_by runs the hash even when the email doesn't exist, so timing stays flat. Hand-rolled lookups lose that.

5.3 Password reset

"Forgot password" is a second way to log in, so it must be as strong as the first. A safe reset flow:

  1. Show the same message whether or not the email exists: "If that address has an account, we've sent a link."
  2. Use a long random token. Store only its hash, or sign it.
  3. Short-lived (15 min) and single-use. A password change kills it.
  4. Rate-limit the endpoint (see section 08).
sim: reset token lifecycle15-minute token, compressed to 15 seconds
No token yet.
Rails 8 reset tokens are signed, expire in 15 minutes, and die when the password changes.

5.4 Multi-factor authentication (MFA)

Passwords get phished, guessed and reused. A second factor means a stolen password isn't enough. From weakest to strongest (roughly):

  • SMS codes: better than nothing, but vulnerable to SIM-swap attacks and phishing.
  • Authenticator codes: good, but a fake site can relay the code within 30 seconds.
  • Push approvals: attackers spam them until someone taps Approve. Use number matching.
  • Passkeys: tied to the real domain, so phishing sites get nothing useful. The best option.

Give recovery codes, require MFA for staff, and treat "turn off MFA" as a sensitive action.

No MFA built in. Reach for rotp (authenticator apps) or webauthn (passkeys), or devise-two-factor with Devise.

5.5 After login: sessions

After login, the browser carries a cookie that says "still Alice". The next section is about keeping that cookie out of the wrong hands.