Lesson 1’s scavenger hunt found passwords stored in plaintext. Lesson 3 pointedly refused to seed them. This is the lesson that pays those setups off: registration, sign-in, sign-out, and the auth extractor that will gate order history — plus the return of j2ee/j2ee, finally stored the way a password should be. Checkpoint: lesson-08.

The whole password policy, two functions

argon2 is the port’s eighth and final planned dependency, and src/auth.rs is everything the app knows about passwords:

pub fn hash_password(password: &str) -> Result<String, AppError> {
    let salt = SaltString::generate(&mut OsRng);
    Argon2::default()
        .hash_password(password.as_bytes(), &salt)
        .map(|hash| hash.to_string())
        .map_err(|_| AppError::Hashing)
}

pub fn verify(stored_hash: &str, password: &str) -> bool {
    PasswordHash::new(stored_hash)
        .map(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
        .unwrap_or(false)
}

What you get for those few lines: argon2id, a random salt per password, and the PHC string format — $argon2id$v=19$m=19456,t=2,p=1$… — which stores the algorithm and its parameters inside the hash, so the column documents itself and can be re-hashed forward when parameters age. Note verify returns a plain bool: a wrong password, a corrupt column, and an unparseable hash are all just “no.” Sign-in has no use for the distinction, and APIs that offer unused distinctions grow bugs in the gaps.

Hashing is deliberately slow — tens of milliseconds, that’s the security property. At course scale, calling it inline is fine; a high-traffic service wraps these calls in tokio::task::spawn_blocking, and now you know why.

The demo users return

Migration 0003_signon_seed.sql inserts j2ee and ACID with argon2 hashes, generated by a twelve-line example binary you’ll keep around:

cargo run --example mkhash -- j2ee

Run the app and watch the migration machinery from lesson 3 earn its keep: your existing jpetstore.db gets exactly the one new migration applied, no rebuild, no reset. Then peek at the column — sqlite3 jpetstore.db 'SELECT * FROM signon;' — and compare it against what lesson 1’s scavenger hunt found in the original. Same users, same demo passwords, but the column is now useless to whoever steals it.

Registration is a transaction

The original’s registration writes three tables — signon, account, profile — and so does ours, atomically:

let mut tx = pool.begin().await?;
sqlx::query!("INSERT INTO signon …").execute(&mut *tx).await?;
sqlx::query!("INSERT INTO account …").execute(&mut *tx).await?;
sqlx::query!("INSERT INTO profile …").execute(&mut *tx).await?;
tx.commit().await

Any failure before commit rolls back all three inserts — lesson 10 leans on this same machinery for orders. And notice what’s not here: no “check if username exists” query. The signon primary key makes duplicates impossible, the first insert trips a unique violation, and the handler matches on it:

Err(err) if db::account::is_unique_violation(&err) => {
    // re-render the form: "That username is taken."
}

Check-then-insert is a race you lose on a bad day; the database was always the arbiter of uniqueness, so let it arbitrate.

One structural detail worth copying into your own projects: NewAccount carries the ten profile fields and deliberately not the password. The password travels alone, from form to hash to database, and never sits on a struct that might get debug-logged.

Sign-in, and the id swap

let ok = stored.as_deref().is_some_and(|hash| auth::verify(hash, &form.password));
if !ok {
    // "Wrong username or password." — one message for both cases
}

session.cycle_id().await?;
session.insert(USER_KEY, &form.username).await?;

Two security decisions live in these lines. The single error message means the form never confirms which usernames exist. And cycle_id issues a fresh session id at the privilege boundary — whatever id the browser carried while anonymous is worthless after sign-in, which closes session fixation. The part students usually find surprising: the session data survives the id swap. Your cart, added anonymously, is still there after you sign in. That’s the “cart survives login” behavior real shops have, and it fell out of doing the security right.

Sign-out is the blunt instrument: session.flush() — everything goes, cart included, exactly like the original’s signoff() invalidating the whole HttpSession.

Proof by extractor

The account page is gated by its own argument list:

pub struct AuthUser(pub String);

pub async fn account(AuthUser(username): AuthUser) -> AppResult<Html<String>> {
    Ok(Html(AccountTemplate { username }.render()?))
}

AuthUser implements FromRequestParts: it pulls the session, looks for the signed-in username, and rejects with a redirect to /signin if there isn’t one. A handler that takes an AuthUser cannot run for an anonymous visitor — the type system enforces what Spring Security does with filter chains and annotations. This is lesson 4’s parse-don’t-validate one more time, aimed at authentication: an AuthUser value is proof the check happened, and lesson 10’s order pages will simply ask for one.

Build it

File Action What goes in it
Cargo.toml generated cargo add argon2 --features std
src/auth.rs write The two functions shown above, plus tests: roundtrip, wrong password, garbage hashes (including the original’s 'j2ee' column contents)
examples/mkhash.rs write argon2 + SaltString::generate + println — twelve lines
migrations/0003_signon_seed.sql write Two INSERTs with hashes you generated via mkhash
src/domain/account.rs write NewAccount — ten fields, no password
src/db/account.rs write password_hash, the three-insert transaction create, is_unique_violation; tests: seeded j2ee verifies, register-then-read-back, duplicate is a unique violation
src/web/account.rs write AuthUser + FromRequestParts shown above; signin form/POST, register form/POST, signout, the gated account page
src/web/error.rs modify A Hashing variant, mapped to 500
src/web/mod.rs, src/main.rs, src/domain/mod.rs, src/db/mod.rs modify mod auth;, module declarations, five new routes
templates/signin.html, register.html, account.html write Forms with an optional error line; account shows the username and a sign-out button
templates/base.html modify An Account link in the header
.sqlx/ generated cargo sqlx prepare

Done when: anonymous /account answers 303 to /signin; a wrong password re-renders the form with the message; j2ee/j2ee lands on “Signed in as j2ee”; an item added before sign-in is still in the cart after; sign-out re-gates the account page; and cargo test shows 20 passing.

Answer key: lesson-07...lesson-08.

Challenge: don’t leak who exists

The error message is the same for unknown users and wrong passwords, but the timing isn’t: an unknown user skips the ~50ms argon2 verification, so a stopwatch can still enumerate usernames. Close the gap — when the user doesn’t exist, verify the password against a throwaway hash anyway and discard the result. Generate one dummy hash at startup, not per-request, and convince yourself with time curl before and after.

Next lesson: checkout. The forms, the confirmation page — and the reason there is no credit card column for them to write to.