Lesson 2 - Password Hashing and the Current User

Welcome to Password Hashing and the Current User

In Lesson 1 you built the skeleton of the OAuth2 password flow, but two big holes are still wide open. First, the login endpoint never actually checked the password — anyone could log in as anyone. Second, even if you stored passwords, storing them as plain text would be a catastrophe: one leaked database and every account is compromised. This lesson closes both holes. You’ll learn to hash passwords so the real password is never written down anywhere, verify a submitted password at login, and write a get_current_user dependency that turns a token back into the user who’s calling — rejecting anything invalid with a 401.

We’ll keep the token simple for now (it’s still basically the username), so we can focus on hashing and the current-user pattern. Real signed JWTs arrive in Lesson 3.

By the end of this lesson, you will be able to:

  • Explain why passwords must be hashed and never stored or logged in plain text
  • Hash passwords with pwdlib and bcrypt, and verify a password against a hash
  • Verify the password at the /token login endpoint and reject bad credentials with 401
  • Write a get_current_user dependency that resolves a token to a user

You’ll lean on the dependency-injection skills from Module 4 again. Let’s begin.


Why Hash, and How to Hash with bcrypt

Storing a password as plain text means that anyone who reads your database — an attacker, a curious employee, a leaked backup — instantly knows every user’s password. Since people reuse passwords, that one leak can unlock their email and bank accounts too. The fix is to never store the password itself. Instead you store a hash: the output of a one-way function that’s easy to compute forward but practically impossible to reverse. At login you hash what the user typed and compare hashes; you never need the original.

We use bcrypt, a hashing algorithm built for passwords. It’s deliberately slow (which frustrates attackers trying billions of guesses) and salted (each hash includes random data, so identical passwords produce different hashes). To call it from Python we use pwdlib — the library FastAPI’s current docs recommend — with its explicit bcrypt hasher:

from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher

pwd = PasswordHash((BcryptHasher(),))

hashed = pwd.hash("wonderland")
print("hash prefix:", hashed[:7])
print("verify correct:", pwd.verify("wonderland", hashed))
print("verify wrong:", pwd.verify("wrong", hashed))
hash prefix: $2b$12$
verify correct: True
verify wrong: False

Three things to notice. The hash starts with $2b$ — that’s bcrypt’s signature — followed by 12, the cost factor (how much work the algorithm does). The full hash is a long string we’re truncating on purpose; you’d never print a real one. And pwd.verify(...) is how you check a password: it returns True for the right one and False otherwise, all without ever un-hashing anything. That’s the entire toolkit — hash when a user sets a password, verify when they try to log in.


Verifying the Password at the /token Endpoint

Now let’s plug hashing into a real login flow. We need somewhere to keep users; for the lesson we’ll use an in-memory dictionary, storing each user’s hashed password — never the plain one. A small register helper hashes the password as the account is created:

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

users = {}

def register(username, password):
    users[username] = {
        "username": username,
        "hashed_password": pwd.hash(password),
    }

register("alice", "wonderland")

With a user on file, the /token endpoint can finally do its job. Lesson 1 returned a token for anyone; this version looks up the user, calls pwd.verify against the stored hash, and rejects bad credentials with a 401:

@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = users.get(form.username)
    if not user or not pwd.verify(form.password, user["hashed_password"]):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    # Still a simple token (the username) for now — real JWTs come in Lesson 3.
    return {"access_token": user["username"], "token_type": "bearer"}

Notice the error message says “Incorrect username or password” — not which one was wrong. That’s deliberate: telling an attacker that the username exists but the password failed leaks information. Let’s try both a correct and an incorrect login:

from fastapi.testclient import TestClient

client = TestClient(app)

good = client.post("/token", data={"username": "alice", "password": "wonderland"})
print("login correct:", good.status_code, good.json())

bad = client.post("/token", data={"username": "alice", "password": "nope"})
print("login wrong: ", bad.status_code, bad.json())
login correct: 200 {'access_token': 'alice', 'token_type': 'bearer'}
login wrong:  401 {'detail': 'Incorrect username or password'}

The right password gets a token; the wrong one gets a 401. The password check is now real — the only thing still fake is the token itself, which is just the username.

Why bcrypt, and why pwdlib

bcrypt is built specifically for passwords: it’s one-way (you can’t recover the password from the hash), salted (each hash bakes in random data, so two users with the same password get different hashes), and intentionally slow (which cripples brute-force guessing). Because of these properties, you should never store or log plain-text passwords — hash on the way in and verify at login, and that’s it. We reach for pwdlib rather than the older passlib because passlib is currently broken with bcrypt 5, while pwdlib is the library FastAPI’s current docs recommend. The bcrypt cost factor (12 above) and the SECRET/signing setup you’ll meet in Lesson 3 are values to change in production and keep out of source control.


Turning a Token Into the Current User

Verifying the password is half the job. The other half: when a request arrives carrying a token, who is it from? We answer that with a dependency called get_current_user. It depends on oauth2_scheme (which extracts the Bearer token), decodes the token into a username, looks that user up, and returns them — or raises 401 if the token doesn’t point at a real user.

Because our token is still just the username, “decoding” is a simple lookup. We’ll write a small fake_decode_token to make the seam obvious — in Lesson 3 it becomes real JWT decoding, and the rest of this code won’t have to change:

def fake_decode_token(token):
    # Token is just the username for now; Lesson 3 swaps this for real JWT decoding.
    return users.get(token)

def get_current_user(token: str = Depends(oauth2_scheme)):
    user = fake_decode_token(token)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user

Any endpoint that depends on get_current_user is now protected and knows who’s calling. Here’s a /me route that returns the logged-in user’s public info — note it never returns the hashed password:

@app.get("/me")
def read_me(current_user: dict = Depends(get_current_user)):
    return {"username": current_user["username"]}

Let’s exercise every path: a valid token, no token at all, and a token that doesn’t match any user.

token = good.json()["access_token"]

with_token = client.get("/me", headers={"Authorization": f"Bearer {token}"})
print("me with token:", with_token.status_code, with_token.json())

no_token = client.get("/me")
print("me no token:  ", no_token.status_code, no_token.json())

bad_token = client.get("/me", headers={"Authorization": "Bearer not-a-real-user"})
print("me bad token: ", bad_token.status_code, bad_token.json())
me with token: 200 {'username': 'alice'}
me no token:   401 {'detail': 'Not authenticated'}
me bad token:  401 {'detail': 'Invalid authentication credentials'}

Three different outcomes from one dependency. A valid token returns the user. A missing token is stopped by oauth2_scheme before your code runs (Not authenticated). An invalid token reaches get_current_user, fails the lookup, and gets your Invalid authentication credentials message. Every protected endpoint now inherits this behavior just by depending on get_current_user — and when Lesson 3 swaps fake_decode_token for real JWT verification, none of these endpoints will need to change.


Practice Exercises

Exercise 1: Same password, different hash

You register two users who both choose the password "hunter2". Will their hashed_password values be identical? Why or why not?

Hint

No. bcrypt is salted — each call to pwd.hash mixes in fresh random data, so the same password produces a different hash every time. That’s a feature: an attacker who sees the stored hashes can’t tell that the two users picked the same password, and can’t precompute results.

Exercise 2: Why one message for two failures?

The /token endpoint returns the same "Incorrect username or password" whether the username doesn’t exist or the password is wrong. Why not give a more specific message like “no such user”?

Hint

A specific message leaks information. “No such user” vs. “wrong password” tells an attacker which usernames are real, helping them target accounts. A single, vague message keeps both failure cases indistinguishable from the outside.

Exercise 3: No token vs. bad token

Calling /me with no token returned Not authenticated, but calling it with Bearer not-a-real-user returned Invalid authentication credentials. Two different messages — what produced each?

Hint

oauth2_scheme (OAuth2PasswordBearer) handles the missing header and raises Not authenticated before your code runs. A token that’s present but doesn’t resolve to a user gets past that and is caught inside get_current_user, which raises your Invalid authentication credentials.


Summary

You closed the two holes from Lesson 1. Passwords are now hashed with bcrypt via pwdlib — stored as a one-way, salted, deliberately slow $2b$... value, never in plain text. The /token endpoint verifies the submitted password against that hash with pwd.verify and returns a 401 for bad credentials, using a single message so it doesn’t leak which usernames exist. And a get_current_user dependency turns an incoming token into the calling user, returning them on success and raising 401 for tokens that don’t resolve — protecting every endpoint that depends on it. The only thing still simplified is the token itself, which remains the username for one more lesson.

Key Concepts

  • Password hashing — storing a one-way hash instead of the password, so the real password is never written down.
  • bcrypt — a salted, slow, one-way hashing algorithm built for passwords (hashes start with $2b$).
  • pwdlib — the recommended library for hash and verify; used because passlib breaks on bcrypt 5.
  • get_current_user — a dependency that decodes a token, looks up the user, and raises 401 if invalid.
  • Generic auth errors — a single “incorrect username or password” message avoids leaking which usernames exist.

Why This Matters

Plain-text passwords are one of the most damaging mistakes an API can make, and “hash with bcrypt, verify at login” is the baseline every reviewer and interviewer expects you to know. Just as important is the get_current_user pattern: it concentrates “who is calling, and are they allowed in?” into one reusable dependency, so the rest of your app stays clean and consistent. With real hashing in place and the current-user seam established, you’re set up for the final piece — replacing the placeholder token with a real, signed, expiring JWT.


Next Steps

Continue to Lesson 3 - JWT Access Tokens

Replace the placeholder token with a real, signed, expiring JWT and verify it inside get_current_user.

Back to Module Overview

Return to the Authentication and Security module overview


Continue Building Your Skills

You now hash passwords the right way, verify them at login, and resolve tokens to users through a single dependency — the heart of any authenticated API. The last gap is the token itself, which is still just a username anyone could fake. Next you’ll make it real: a signed JWT that carries the user’s identity, expires on its own, and can be verified by the server without a database lookup — slotting straight into the get_current_user seam you just built.