Why Hash Passwords?
Storing plain-text passwords is the most critical security mistake you can make. Hashing transforms a password into a fixed-length string that cannot be reversed.
| Approach | Security | Reversible |
|---|---|---|
| Plain text | ❌ Critical | Yes |
| MD5 / SHA-1 | ❌ Broken | With lookup tables |
| bcrypt / argon2 | ✅ Strong | Practically impossible |
FastAPI + passlib with bcrypt provides industry-standard password hashing out of the box.
Setting Up passlib + bcrypt
pip install passlib[bcrypt] bcrypt
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
CryptContextmanages multiple hashing schemes (you can migrate algorithms later)deprecated="auto"automatically flags old algorithms so they are re-hashed on login- bcrypt includes a salt automatically - no need to manage salts manually
hash_password()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
- bcrypt output includes the algorithm, cost factor, salt, and hash in a single string:
$2b$12$LJ3m4ys3Lk0TSwHnbfdZqu3dQmRV1sLXzBwlCqE.b3eXKz0N0Hbwq - The
$2b$12$prefix identifies bcrypt with cost factor 12 - Each call produces a different hash because of the random salt
verify_password()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
- Extracts the salt and algorithm from the stored hash
- Hashes the plain-text password with the same salt and compares
- Constant-time comparison prevents timing attacks
User Registration Flow
Pydantic Models
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserOut(BaseModel):
username: str
email: EmailStr
disabled: bool = False
Registration Endpoint
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
router = APIRouter()
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate, db: Session = Depends(get_db)):
existing = db.query(User).filter(
(User.username == user.username) | (User.email == user.email)
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username or email already registered",
)
hashed = hash_password(user.password)
db_user = User(
username=user.username,
email=user.email,
hashed_password=hashed,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
- Never return the hashed password in the response
- Validate uniqueness of
usernameandemailbefore inserting - Always hash the password before storing in the database
- Return
201 Createdfor successful registration
get_current_user Dependency
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = get_user(username=username)
if user is None:
raise credentials_exception
return user
User Response Model
Always separate input models from output models to avoid leaking sensitive data:
class UserInDB(UserOut):
hashed_password: str
class UserOut(BaseModel):
username: str
email: EmailStr
disabled: bool = False
model_config = {"from_attributes": True}
@app.get("/users/me", response_model=UserOut)
async def read_users_me(current_user: User = Depends(get_current_user)):
return current_user
UserInDBextends the output model withhashed_passwordfor internal use- The endpoint’s
response_model=UserOutstrips the password automatically - Never expose
UserInDBin any API response schema
Complete Auth Flow
Client FastAPI Database
│ │ │
│── POST /register ──────►│ │
│ {username,email,pw} │── hash_password(pw) ───►│
│ │── INSERT user ──────────►│
│◄── 201 {username,email}─│ │
│ │ │
│── POST /token ─────────►│ │
│ {username,password} │── verify_password() ────►│
│ │── create JWT ───────────►│
│◄── {access_token} ──────│ │
│ │ │
│── GET /users/me ───────►│ │
│ Authorization: Bearer │── jwt.decode() ─────────►│
│◄── 200 {username,email}─│ │
Key Takeaways
- Use
passlibwithbcryptscheme - never write custom hashing logic hash_password()salts and hashes in one call;verify_password()compares in constant time- The registration endpoint must validate uniqueness and return a safe
UserOutmodel get_current_userdecodes the JWT and fetches the user - inject it into any protected route- Separate
UserCreate(input),UserOut(safe output), andUserInDB(internal) models - Never return
hashed_passwordfrom any API endpoint - Always hash passwords before storing, never before comparison