Let’s Start With a Real Problem
Go back and look at every user_service.py we’ve written since Chapter 5. Every single one contains this line:
hashed_password = f"hashed_{user_in.password}"
That’s not hashing. That’s a password with the word "hashed_" stuck in front of it. If your database were ever leaked — and real databases do get leaked — every single user’s actual password would be sitting right there in plain sight, readable by anyone. This has been a deliberate placeholder throughout the series so we could focus on other concepts one at a time. Now it’s time to fix it for real, and go one step further: build a genuine login system that proves who someone is using an industry-standard JWT (JSON Web Token).
What You’ll Learn
- Why plain text (or fake “hashed”) passwords are a serious security risk
- How real, one-way password hashing works using
bcrypt - What a JWT actually is — its three parts, and what “stateless” authentication means
- How to build a real login endpoint that verifies a password and issues an access token
- Theory + hands-on assignment questions to lock in what you’ve learned
💡 Where this fits in the series: This chapter covers registration with real hashing and issuing a token on login. Chapter 15 goes deeper into verifying tokens and building the “who is currently logged in?” dependency. Chapter 16 then formalizes everything using FastAPI’s official OAuth2 password flow — the version that powers Swagger UI’s “Authorize” button. We’re building this up in layers on purpose, so each piece is easy to absorb before the next one adds complexity.
Theory: Hashing vs. Encryption, and What a JWT Actually Is
Hashing is not the same as encryption. Encryption is reversible — if you have the key, you can decrypt data back to its original form. Hashing is deliberately one-way. You can turn "SuperSecret123" into a hash, but you cannot turn that hash back into "SuperSecret123". To check a login attempt, you don’t decrypt the stored hash — you hash the newly typed password and compare the two hashes.
bcrypt (which we’ll use here) also automatically adds a random salt to each password before hashing, so even two users with the identical password "password123" end up with completely different stored hashes — this defeats precomputed “rainbow table” attacks.
What Is a JWT?
A JWT (JSON Web Token) is a compact, self-contained string with three parts, separated by dots:
xxxxx.yyyyy.zzzzz
header . payload . signature
| Part | Contains |
|---|---|
| Header | Which algorithm was used to sign the token |
| Payload | The actual data (“claims”) — e.g., which user this is, when it expires |
| Signature | A cryptographic proof that the token wasn’t tampered with |
The key idea: once your server signs a token with a secret key, it can verify that token later without needing to look anything up in a database — the signature alone proves the token is genuine and unmodified. This is what people mean by JWTs being stateless: the server doesn’t need to “remember” who’s logged in; the proof travels inside the token itself.
⚠️ Important: A JWT’s payload is only signed, not encrypted — anyone can decode and read it (try pasting one into jwt.io). Never put secrets like passwords inside a JWT payload; only put non-sensitive identifying information, like a user ID.
Program 1: Real Password Hashing at Registration
Program Objective
Build a small, standalone FastAPI project where user passwords are hashed using bcrypt before ever touching the database, replacing the placeholder trick used throughout earlier chapters.
Step-by-Step Implementation
Step 1: Create the project
uv init password-hashing-demo
cd password-hashing-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
password-hashing-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── security.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── user_service.py
│ └── routers/
│ ├── __init__.py
│ └── users.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Password Hashing Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
settings = Settings()
app/core/security.py
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
is_active: bool
model_config = ConfigDict(from_attributes=True)
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_username(db: Session, username: str) -> User | None:
return db.query(User).filter(User.username == username).first()
app/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.db.database import Base, engine
from app.models import user # noqa: F401
from app.routers import users
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
app/core/security.pyis a brand-new file, and its whole job is handling password security in one place:CryptContext(schemes=["bcrypt"], deprecated="auto")— sets uppasslibto use thebcrypthashing algorithm.deprecated="auto"means if you ever add a newer scheme in the future,passlibcan automatically flag old hashes for re-hashing on next login — a nice built-in migration path.hash_password(...)— turns a plain password into a secure hash. This is what runs during registration.verify_password(...)— checks a plain password attempt against a stored hash, returningTrue/False. This is what we’ll use for login in Program 2.
user_service.pynow callshash_password(user_in.password)instead of the oldf"hashed_{user_in.password}"placeholder — this is the actual fix for the problem described at the start of this chapter.- Nothing else in this program changed from the patterns established in earlier chapters — the router, schemas, and database setup are all exactly what you’ve already learned. Real security work often looks like this: a small, focused, well-isolated change, not a rewrite of everything around it.
Execution & Testing
uv run fastapi dev app/main.py
- Open
http://127.0.0.1:8000/docsand callPOST /users/registerwith a sample user. - Verify the hash directly: run
sqlite3 app.db "SELECT username, hashed_password FROM users;"in your terminal (or openapp.dbwith any SQLite viewer). Confirm thehashed_passwordcolumn contains a long, unreadable string starting with$2b$— not the plain password, and not the old"hashed_..."placeholder. - Register the exact same password for a second, different username, and confirm the two resulting hashes in the database are completely different from each other — proof that bcrypt’s automatic salting is working.
Program 2: A Login Endpoint That Issues a JWT
Program Objective
Extend the project with a POST /auth/login endpoint that verifies a username and password against the stored hash, and — if correct — returns a signed JWT access token the client can use to prove their identity on future requests.
Step-by-Step Implementation
Step 1: Create the project
uv init jwt-login-demo
cd jwt-login-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
jwt-login-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── security.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── token.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── user_service.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ └── auth.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "JWT Login Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
# NOTE: This is a hardcoded placeholder secret for learning purposes only.
# Chapter 17 covers moving secrets like this into a proper .env file.
jwt_secret_key: str = "a-very-insecure-demo-secret-change-this"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
settings = Settings()
app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {"sub": subject, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
is_active: bool
model_config = ConfigDict(from_attributes=True)
app/schemas/token.py
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_username(db: Session, username: str) -> User | None:
return db.query(User).filter(User.username == username).first()
app/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import LoginRequest, TokenResponse
from app.services.user_service import get_user_by_username
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=TokenResponse)
def login(credentials: LoginRequest, db: Session = Depends(get_db)):
user = get_user_by_username(db, credentials.username)
if user is None or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password.",
)
access_token = create_access_token(subject=user.username)
return TokenResponse(access_token=access_token)
app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.db.database import Base, engine
from app.models import user # noqa: F401
from app.routers import auth, users
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
app.include_router(auth.router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
config.pyadds three new settings:jwt_secret_key(used to sign and verify every token — if this leaks, anyone can forge valid tokens),jwt_algorithm(HS256is the standard, widely-supported choice), andaccess_token_expire_minutes(tokens shouldn’t last forever — a shorter lifetime limits the damage if one is ever stolen).create_access_token(subject: str)insecurity.pyis the heart of this program:expire = datetime.now(timezone.utc) + timedelta(minutes=...)calculates exactly when this token should stop being valid.to_encode = {"sub": subject, "exp": expire}builds the token’s payload (its claims)."sub"(subject) is a standard JWT claim meaning “who this token is about” — here, the username."exp"is another standard claim; libraries likepython-joseautomatically reject a token as expired once this time passes, without you writing that check yourself.jwt.encode(...)combines the payload with the secret key and algorithm to produce the final signed token string.
schemas/token.pydefines two small, focused schemas:LoginRequest(what the client sends) andTokenResponse(what we send back) —token_type: str = "bearer"tells the client exactly how to use this token later (as anAuthorization: Bearer <token>header, which we’ll wire up properly in Chapter 15).routers/auth.pyties it together:- It looks up the user by username, then calls
verify_password(...)— comparing the submitted plain password against the stored hash, never comparing raw passwords directly. - Security detail worth noticing: whether the username doesn’t exist or the password is wrong, we raise the exact same
401error with the exact same generic message. This is deliberate — if we said “user not found” versus “wrong password” differently, an attacker could use that difference to discover which usernames are registered on your system (a technique called username enumeration). Always keep login failure messages generic.
- It looks up the user by username, then calls
Execution & Testing
uv run fastapi dev app/main.py
- Open
http://127.0.0.1:8000/docs, register a user viaPOST /users/register. - Call
POST /auth/loginwith the correct username and password. Confirm you receive a response like{"access_token": "eyJhbGciOi...", "token_type": "bearer"}. - Inspect your token: copy the
access_tokenvalue and paste it into jwt.io — confirm you can see your username under"sub"and an expiry timestamp under"exp"in the decoded payload, in plain readable JSON, exactly as the theory section explained. - Call
POST /auth/loginagain with the correct username but a wrong password — confirm you get a generic401 Incorrect username or password, not a message revealing whether the username itself was valid. - Try logging in with a username that was never registered at all — confirm you get the exact same generic
401message as step 4.
Tips & Instructions for Developers
- ✅ Always hash passwords with a dedicated library like
passlib(or a directbcryptcall) — never write your own hashing scheme, and never use general-purpose hashes like plainmd5orsha256for passwords (they’re too fast, which makes brute-forcing easier). - ✅ Keep every password-related function in one dedicated
core/security.pyfile — it makes security code easy to audit and review in one place. - ✅ Always include an
"exp"claim in your JWTs, and keep the expiry window reasonably short for access tokens. - ✅ Return the exact same generic error message for “user not found” and “wrong password” — this small detail meaningfully improves your API’s security posture.
- ✅ Treat your
jwt_secret_keyas seriously as a password itself — anyone with it can forge valid tokens for any user. - ❌ Don’t put sensitive data (passwords, credit card numbers, internal flags) inside a JWT payload — remember, it’s signed, not encrypted, and anyone can read it.
- ❌ Don’t reuse the same secret key across different environments (development, staging, production) — a leak in one shouldn’t compromise the others.
Official FastAPI Documentation References
- Security — OAuth2 with Password (and hashing), Bearer with JWT tokens
- Security — First Steps
- python-jose Documentation
- Passlib Documentation
- jwt.io — JWT Debugger
Theory Questions
- Why is hashing fundamentally different from encryption, and why does that difference matter for storing passwords?
- What does “salting” a password hash protect against?
- What are the three parts of a JWT, and what does each one contain?
- Why is a JWT described as “stateless,” and what problem does that solve compared to a traditional server-side session?
- Why do we return the same generic error message for “user not found” and “incorrect password” during login?
Practical Assignment
- Add a “changed password” test: Starting from Program 1’s project, register a user, then manually try
verify_password()with a slightly different password (e.g., one extra character) in a quick Python shell (uv run python) to confirm it correctly returnsFalse. - Shorten the token lifetime: Starting from Program 2’s project, change
access_token_expire_minutesto1, log in, wait over a minute, then decode the token at jwt.io again — note the"exp"timestamp and confirm (by comparing it to the current time) that the token would now be considered expired. - Add a
/auth/mepreview endpoint: Starting from Program 2’s project, add aGET /auth/whoamiendpoint that accepts a token as a query parameter (e.g.?token=...), manually decodes it withjwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]), and returns the"sub"claim. This is a rough preview of what Chapter 15 will build properly as a reusable dependency.
Summary
In this chapter, you learned:
- Why the placeholder “hashing” used throughout earlier chapters was a real security risk
- How to hash and verify passwords correctly using
bcryptviapasslib - What a JWT is made of, and why it enables stateless authentication
- How to build a real login endpoint that verifies credentials and issues a signed, expiring access token
- Why generic error messages matter for preventing username enumeration
