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.
Two people, same password. See what each storage method gives a thief.
| strategy | alice | bob | verdict |
|---|
# 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.
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.
Try the attacker's email list on both forms. Which one gives secrets away?
wrong-guessleaky login
generic login
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:
- Show the same message whether or not the email exists: "If that address has an account, we've sent a link."
- Use a long random token. Store only its hash, or sign it.
- Short-lived (15 min) and single-use. A password change kills it.
- Rate-limit the endpoint (see section 08).
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.
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.