Hash the password, hand out a token, and make absolutely sure no one can read someone else’s expenses.
So Phase 2 gave my app a mouth. It could finally talk — create, read, update, and delete expenses over real HTTP endpoints, all clicking together through /docs. But there was a giant, deliberately-ignored problem sitting in the middle of it: the door had no lock. Anyone who could reach the server could read, edit, or delete anything. And every expense I created was quietly stamped with the same hardcoded owner — a “dev user” whose id I’d nailed into the code with a # TEMP note and a promise to fix it “in Phase 3.”
Well. It’s Phase 3. Time to pay that debt.
This is where the app grows a bouncer. The buzzword is auth, which actually hides two jobs that sound the same and aren’t: authentication [“who are you?”] and authorization [“okay, but are you allowed to touch this?”]. I went in thinking auth was “add a login form” and came out having learned about one-way hashing, signed tokens, a security bug with the excellent name IDOR, and why the same password can produce two different hashes. Let me dump what I learned [and the parts that tripped me up, because — as usual — there were several].
Auth is the one phase where you have to stop thinking like a builder and start thinking like the person trying to rob you. So every step below is really “here’s a way an attacker wins, and here’s the gap I closed to stop them.”
The structure. Let’s call it PHASE 3 — The Lock:
- Give the
Usertable somewhere to store a password [a hashed one, never the real thing] - Password hashing helpers — turn a password into something safe to store
- POST /auth/register — sign up with a hashed password
- Understand what a JWT actually is [it’s just a signed string, and it’s readable]
- POST /auth/login — check the password, hand back a token
- get_current_user — the gatekeeper that turns a token back into a user
- Lock every expense endpoint and scope it to the logged-in owner
- Retire the hardcoded
DEV_USER_IDfor good - Prove two users can’t see each other’s data
First, the new ideas [and yes, this time there IS stuff to install]
Unlike Phase 2, this phase actually adds libraries — so the pip freeze habit is back on. Three new tools, in plain language:
- passlib [with bcrypt] — the password shredder. bcrypt is the actual algorithm; passlib is the friendly wrapper around it. Its whole job is to turn a password into a hash — a scrambled string you can store safely — using a method that’s deliberately slow and salted [more on both of those below].
-
PyJWT — the token machine. It creates and verifies JWTs, the signed strings that let a user prove “I’m logged in” on every request without re-sending their password. [Gotcha you’ll hit: you
pip install pyjwtbut youimport jwt. The names don’t match. File that away now.] - python-multipart — an unglamorous helper that lets FastAPI read form data, which the standard login flow uses. You don’t call it directly; FastAPI just needs it present.
pip install "passlib[bcrypt]" pyjwt python-multipart
pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode
The quotes around "passlib[bcrypt]" matter — the brackets tell pip to pull bcrypt in as an extra. And the moment you install something, freeze it, so the next machine [including future-you] rebuilds the identical environment.
Step 1: Give users a place to store a password [a hashed one]
Here’s the rule that governs this entire phase, and it’s worth tattooing somewhere: you never store the password. You store a one-way hash of it.
My User model from Phase 1 had name, email, and timestamps — but no password field at all, because I built it before auth existed. So it needs one new column:
hashed_password: Mapped[str] = mapped_column(String, nullable=False)
Enter fullscreen mode Exit fullscreen mode
Two deliberate choices:
-
It’s called
hashed_password, notpassword. The name is a permanent reminder to everyone [me, in three weeks] that this field holds a hash, never plaintext. If you ever spot raw text in there, something is badly broken. -
It’s not unique and not indexed [unlike
email]. You never look a user up by their hash — you find them by email, then verify the hash in code. Indexing it would be wasted effort.
A new column means a new migration. Same autogenerate → review → apply rhythm from Phase 1:
alembic revision --autogenerate -m "add hashed_password to users"
Enter fullscreen mode Exit fullscreen mode
I reviewed the generated file [op.add_column(... nullable=False)] and then hit the wrinkle that became my first real lesson this phase.
Gotcha #1 — the NOT NULL wall. SQLite refuses to add a
NOT NULLcolumn to a table that already has rows, because those existing rows have no value for it. My table had that old seeded dev user in it. Two honest paths: the production way [add the column nullable, backfill every row, then a second migration to flip it toNOT NULL— zero data loss], or the learning way [my only data was throwaway, so wipe it and rebuild]. My mentor made me say out loud that we were taking a shortcut. I took it — but now I know the real move for when the data actually matters.
⚠️ Destructive-command warning [the first of this phase]: rebuilding from scratch deletes every local row. Safe here only because it’s junk dev data. Rather than delete the .db file by hand, I stayed inside Alembic — which also proves my whole migration chain works end to end:
alembic downgrade base # drops everything back to empty [the destructive line]
alembic upgrade head # replays all migrations onto empty tables
Enter fullscreen mode Exit fullscreen mode
Step 2: The hashing helpers [hashing is NOT encryption]
Before I could register anyone, I needed two tiny functions: one to hash a password on the way in, one to verify a login attempt later. Best practice says security code lives in its own file, so this is a new security.py:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
Enter fullscreen mode Exit fullscreen mode
The single most important concept here — and it took a beat to click:
-
Hashing is one-way. Encryption is two-way. You can encrypt something and later decrypt it back. You can hash a password, but you can never turn the hash back into the password. That’s the entire point: even I, running the server, can’t see anyone’s password. If my whole database gets stolen, the attacker gets a pile of useless
$2b$...strings, not passwords. That’s the first gap closed. - bcrypt is deliberately slow. Sounds like a bug; it’s a feature. Slowness makes brute-forcing millions of guesses painfully expensive for an attacker while costing a real login a few harmless milliseconds.
-
bcrypt auto-salts. It mixes a random value into every hash, so two people with the same password get different hashes. That kills “rainbow table” attacks [precomputed hash lookups]. I proved this to myself: hashing
"supersecret123"twice gave two totally different strings. Same input, different output — the salt, visibly doing its job. -
deprecated="auto"is the sneaky-clever bit. Because every hash records which algorithm made it [the$2b$prefix is bcrypt’s ID card], I can add a newer algorithm years from now and passlib will keep verifying old hashes and silently upgrade them the next time each user logs in. Nobody gets locked out, nobody resets their password. Algorithm agility, for one word of config.
I tested it in a plain python shell: hash_password("supersecret123") gave a long $2b$... string, verify_password("supersecret123", h) returned True, and verify_password("wrong", h) returned False. Then the version gods showed up.
Gotcha #2 — the 72-byte error on a 14-character password. My first real hash blew up with
ValueError: password cannot be longer than 72 bytes. My password was 14 characters. What?! The culprit: passlib 1.7.4 is old and unmaintained, and modern bcrypt [4.1+] changed an interface it relies on. On its first run, passlib runs an internal self-test that trips the new bcrypt’s 72-byte guard — before my password is ever touched. The fix was to pin bcrypt to the last version passlib understands:pip install "bcrypt==4.0.1" pip freeze > requirements.txtThis was also the moment “why do we freeze?” stopped being abstract — without the pin, a fresh install would grab the broken-for-me newest bcrypt all over again. [The modern alternative is a library called
pwdlib; passlib is on its way out. Noted for a future refactor.]
Step 3: POST /auth/register [where the hash finally gets used]
Two schemas, exactly like Phase 2’s input/output split — but with one security rule bolted on. In schemas.py:
class UserCreate(BaseModel):
"""What a client sends to register."""
name: str = Field(min_length=1, max_length=100)
email: str
password: str = Field(min_length=8)
class UserRead(BaseModel):
"""What the server sends back — no password, no hash, EVER."""
id: int
name: str
email: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
Enter fullscreen mode Exit fullscreen mode
Look at what UserRead is missing: the password and the hash. This is a real gap — if the stored hash ever showed up in an API response, an attacker who can read responses gets a head start on cracking it. Leaving it out of the output schema makes that leak structurally impossible. Same trick as response_model=ExpenseRead in Phase 2, now doing security work.
The endpoint, in main.py:
@app.post("/auth/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register(payload: UserCreate, db: Session = Depends(get_db)):
existing = db.scalars(select(User).where(User.email == payload.email)).first()
if existing is not None:
raise HTTPException(status_code=409, detail="Email already registered")
user = User(
name=payload.name,
email=payload.email,
hashed_password=hash_password(payload.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
Enter fullscreen mode Exit fullscreen mode
-
hashed_password=hash_password(payload.password)is the one line that makes this safe. The plaintext exists only long enough to be hashed, right here, and is never stored. What lands in the database is the$2b$...string. -
The
/auth/prefix namespaces auth routes together — tidy as the API grows. [The neater-still version is a separateAPIRouterfile; leaving that for later so I’m not juggling files mid-concept.] -
409 Conflict is the precise code for “your request is fine, but it clashes with something that already exists” — here, a taken email. More specific than a generic 400. [The fully robust version wraps the insert in a
try/except IntegrityErrorto close a tiny race where two people register the same email at the same instant; the DB’s unique constraint still protects integrity either way. Noted, not built.]
Tested through /docs: register → 201, and the response showed id, name, email, created_at and — crucially — no hashed_password. Registering the same email again → 409.
Step 4: What a JWT actually is [read this before you trust one]
This was the concept I most needed to get before writing more code. A JWT [JSON Web Token, said “jot”] is just a signed string the server hands you at login. You send it back on every future request, and it proves “I’m logged in” — without the server having to remember sessions. The token itself carries the proof.
It has three parts separated by dots: header.payload.signature.
-
Header — which signing algorithm was used [I used
HS256]. -
Payload — the “claims,” i.e. the data. I put in
sub[subject — who the token is about; I store the user’s id] andexp[expiry]. - Signature — the header and payload, signed with my secret key.
Here’s the thing that reframed everything for me: the payload is only base64-encoded, not encrypted. Anyone who grabs the token can read it — paste one into jwt.io and you’ll see the user id in plain text. So the rule is: never put anything secret in a JWT. What a token buys you isn’t secrecy — it’s integrity. Change one character of the payload [say, to a different user’s id] and the signature no longer matches, so the server rejects it. An attacker can’t forge a valid signature because only my server knows the secret key.
Which is exactly why that key can never leak. If someone learns my SECRET_KEY, they can mint valid tokens for any user — a total bypass. So it lives in .env [already gitignored since Phase 0], generated with real randomness:
python -c "import secrets; print(secrets.token_hex(32))"
Enter fullscreen mode Exit fullscreen mode
SECRET_KEY=that_64_char_hex_value
ACCESS_TOKEN_EXPIRE_MINUTES=30
Enter fullscreen mode Exit fullscreen mode
That 30-minute expiry closes another gap: a stolen token stops working within half an hour instead of forever. [Real apps pair a short access token with a longer “refresh token” so users aren’t logged out constantly. Not building refresh tokens this phase — that’d be over-engineering right now.]
Then I wrote the token minter in security.py:
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
Enter fullscreen mode Exit fullscreen mode
Note the timezone-aware UTC expiry — same discipline as Phase 1’s timezone=True columns. Auth timestamps must be unambiguous across servers.
Gotcha #3 — the misspelled keyword that wasn’t a version bug. Setting up
CryptContextearlier, I hitKeyError: unknown CryptContext keyword. I assumed another bcrypt version mess. But my mentor made me read the traceback bottom-up first — and the last line said it plainly: an argument I passed doesn’t exist. I’d typeddepreciated[the accounting word, with ani] instead ofdeprecated. Case-and-spelling-sensitive libraries do not forgive. Reading the traceback saved me from “fixing” something that was never broken.Gotcha #4 — a
.envfile does nothing on its own. MySECRET_KEY = os.environ["SECRET_KEY"]crashed with aKeyError. Turns out I’d been reading env vars withos.getenvall along but never actually loading the.envfile into the environment — myDATABASE_URLonly “worked” because it had a default fallback that quietly kicked in. A.envfile is just text; something has to copy it into the environment first. That something isload_dotenv():from dotenv import load_dotenv load_dotenv() # must run BEFORE anything reads a variableLesson that stuck: loading and reading are two different jobs.
load_dotenv()loads;os.getenv()reads. And my strict, no-fallbackSECRET_KEYline is what exposed the gap the fallback had been hiding — fail-fast earning its keep.
Step 5: POST /auth/login [check the password, hand back a token]
This is where hashing and JWTs meet. I built login the standard FastAPI way, with OAuth2PasswordRequestForm — which, bonus, lights up the green Authorize button in /docs.
@app.post("/auth/login", response_model=Token)
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
user = db.scalars(select(User).where(User.email == form_data.username)).first()
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=401,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(subject=str(user.id))
return {"access_token": access_token, "token_type": "bearer"}
Enter fullscreen mode Exit fullscreen mode
-
The quirk everyone hits: the OAuth2 spec calls the field
username, but I put the email there. So at login you type your email into the box labeled “username.” Normal in FastAPI land. -
The vague error is on purpose. Whether the email doesn’t exist or the password is wrong, I return the exact same
401with the same message. If I distinguished them, an attacker could probe which emails are registered — a leak called user enumeration. One generic message, closed. Do this now, it’s free. -
subject=str(user.id)stamps the user’s id into the token’ssubclaim. That breadcrumb is the whole point — it’s how the next step figures out who a request is from, with no password involved.
The docs also show grant_type, scope, client_id, client_secret fields — all part of the full OAuth2 spec, all safely ignorable for my app. Only username and password matter here.
Tested: right email + password → 200 with a token; wrong password → 401. And it taught me a bit of Python grammar I’d been fuzzy on — : describes [amount: Decimal, a dict pair {"key": "value"}, the : that opens a block] while = assigns [x = 5, or a keyword arg status_code=401]. Same word can flip: subject: str in a definition vs subject=... in a call.
Step 6: get_current_user [the gatekeeper]
This is the piece every locked endpoint leans on. It turns a token back into a real, live user — the concrete form of authentication. First a decode helper in security.py:
def decode_access_token(token: str) -> dict:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
Enter fullscreen mode Exit fullscreen mode
jwt.decode does two jobs at once: verifies the signature [proving I minted it, untampered] and checks the exp [rejecting expired tokens]. Either failure raises. Then the dependency in main.py:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
user_id = payload.get("sub")
if user_id is None:
raise credentials_exception
except jwt.InvalidTokenError:
raise credentials_exception
user = db.get(User, int(user_id))
if user is None:
raise credentials_exception
return user
Enter fullscreen mode Exit fullscreen mode
-
OAuth2PasswordBearerautomatically pulls the token out of the incomingAuthorization: Bearer <token>header [401 if it’s missing] and is what finally enables the Authorize button. -
One
except jwt.InvalidTokenErrorcovers a bad signature, a malformed token, and an expired one [expiry raises a subclass], all funnelling to the same generic 401. No-leak discipline again. -
db.get(User, ...)fetches the actual user rather than blindly trusting the token — so a deleted user’s leftover token stops working. Returning theUserobject is the magic: any endpoint that depends on this gets the logged-in user handed straight to it.
I proved it with a throwaway GET /auth/me that just returns current_user: authorized → 200 with my user; no token → 401.
Gotcha #5 — case-sensitivity, twice. First
Oauth2PasswordBearer is not defined, then again — because “OAuth” is an acronym and both the O and the A are uppercase:OAuth2PasswordBearer. I’d lowercased the A. Python doesn’t care that it’s “obviously” the same word.Gotcha #6 — order matters for dependencies. I also hit
get_current_user is not definedon an endpoint. Not a typo this time — an ordering problem. Python evaluatesDepends(get_current_user)in the function’s default arguments the moment it reads thedefline, soget_current_userhas to be defined above any endpoint that uses it. Moved the gatekeeper up near the top ofmain.py, above the routes. [Same reasonget_dbhas always worked — defined before its consumers.]
Step 7: Lock the endpoints and scope them to the owner [the boss fight]
This is what the whole phase was building toward — and it’s the real difference between authentication [we know who you are] and authorization [you may only touch your own rows]. Every expense endpoint gets the same two changes: add current_user: User = Depends(get_current_user), then use current_user.id — for writes, stamp it; for reads, filter by it.
Create finally retires the shortcut:
expense = Expense(**payload.model_dump(), user_id=current_user.id) # was DEV_USER_ID
Enter fullscreen mode Exit fullscreen mode
And the isolation-critical read — fetch by id AND owner:
@app.get("/expenses/{expense_id}", response_model=ExpenseRead)
def get_one_expense(
expense_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
expense = db.scalars(
select(Expense).where(
Expense.id == expense_id,
Expense.user_id == current_user.id,
)
).first()
if expense is None:
raise HTTPException(status_code=404, detail="Expense not found")
return expense
Enter fullscreen mode Exit fullscreen mode
That double .where(...) closes the scariest gap in the phase — IDOR [Insecure Direct Object Reference]: the bug where a logged-in User A simply asks for User B’s expense by its id. Because the query filters on user_id too, A’s request for B’s row returns nothing → a clean 404. The ownership check lives in the query itself, so I literally cannot forget it later. The list, PATCH, and DELETE endpoints all got the same treatment [filter the list by owner; fetch-scoped-by-owner before updating or deleting], so it’s impossible to even load someone else’s row to change it.
Two small but real decisions:
- 404, not 403, when the row isn’t yours. Saying 403 [“that exists, but you can’t have it”] would leak that the id exists. 404 reveals nothing. Same no-leak instinct as the login error.
-
db.scalars(...)+.first()/.all()—db.scalars()hands me clean model objects [not row-tuples],.first()gives one object orNone[get-one],.all()gives a list [the list endpoint]. Different endings for different needs.
Then the symbolic finish line — deleting the hardcoded owner. But not before checking it’s actually unused:
grep -rn "DEV_USER_ID" .
Enter fullscreen mode Exit fullscreen mode
Habit worth keeping: grep-before-delete. It turns “I think it’s unused” into “I’ve confirmed it’s unused.” Only the definition line showed up, so I deleted it.
DEV_USER_IDis officially retired — 3 phases after I promised it would be.
Step 8: Prove it [the part tutorials skip]
Writing auth code isn’t the same as verifying auth works. So I put on the attacker hat and ran the isolation proof my roadmap demanded, entirely through /docs:
- Registered a second user, Bob.
-
As User A: created an expense [id 1], confirmed
GET /expensesshowed only mine. -
Logged out, authorized as Bob — the attacker:
-
GET /expenses→ empty list[]. Bob has nothing. [If A’s expense showed up here, isolation was broken.] -
GET /expenses/1,PATCH /expenses/1,DELETE /expenses/1→ 404, 404, 404. Bob can’t read, edit, or delete A’s row even though he knows its id.
-
-
Back as User A:
GET /expenses/1→ 200, untouched. Bob’s attacks never landed.
Bob is a perfectly valid, authenticated user — and still can’t touch A’s data. That’s the gap closed, and it’s the difference between authentication and authorization made concrete. Passing this test is the moment the phase actually ended.
Stuff I want to remember [the honest takeaways]
- Never store a password. Store a one-way hash. Hashing ≠ encryption — there’s no “decrypt” button, and that’s the point.
- bcrypt is deliberately slow and auto-salted — same password, different hash every time.
- A JWT’s payload is readable, not encrypted. Never put secrets in it. What it gives you is integrity, guarded by a signature only your server can produce.
- The
SECRET_KEYleaking = total auth bypass. It lives in.env, never in git, and it’s generated with real randomness. - Short token expiry [
exp] limits the damage of a stolen token. - 401 = “you failed to authenticate.” For “that’s not yours,” return 404, not 403 — 403 leaks that the thing exists.
- Keep login and validation errors vague and identical — specific ones leak which emails/rows exist [user enumeration].
- Put the ownership filter in the query [
.where(user_id == current_user.id)], not in an afterthought check you can forget. -
os.getenvreads env vars;load_dotenv()loads the.envfile. Different jobs — you need both. - Dependencies must be defined above the endpoints that use them — default args evaluate at
deftime. - Read tracebacks bottom-up; the last line is the real error. It’ll save you from fixing the wrong thing.
-
grepbefore you delete. Confirm dead code is actually dead. - Freeze after every install — and sometimes you have to pin a version [
bcrypt==4.0.1] to keep an older library happy. - When you cut a corner, label it — and Phase-N-later, actually go back and pay it off.
DEV_USER_ID, you will not be missed.
Next up: Phase 4. The app now has a memory, a mouth, and a lock on the door. It knows who you are and only shows you your own stuff. What it doesn’t have yet is a way to make sense of all that data at scale — filtering, sorting, pagination, maybe some summaries. If Phase 3 gave the app a bouncer, Phase 4 teaches it to actually organize the room. See you there.
답글 남기기