Password Hashing and User Registration
The first rule
Never store a password the way the user typed it. Not in the database, not in logs, not in a "temporary" debug print that always survives until production.
This is not paranoia. Databases get leaked, backups get stolen, junior engineers accidentally SELECT * in front of a screen share. If the raw password sits there in plain text, every one of those mishaps becomes a disaster. Hash it once when the account is created and you remove an entire category of risk.
Hashing in one minute
A hash function takes any input and produces a fixed-size string that cannot be reversed back to the input.
"hunter2" ─► bcrypt ─► $2b$12$KIX...long-blob...lE.0uGiven the blob, there is no algorithm that gives you back hunter2. To check a password later, you hash the new attempt with the same salt and compare the two blobs.
For password storage, the right hash functions are the slow ones: bcrypt, argon2, scrypt. Fast hashes like SHA-256 are the wrong tool here - a modern GPU can try billions of SHA-256 hashes per second. Slow on purpose is the point.
Picking a library
passlib with bcrypt is the comfortable default in the FastAPI world.
pip install "passlib[bcrypt]"A tiny wrapper keeps the hashing code in one place:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)That's it. Two functions, used everywhere else. If you ever want to swap bcrypt for argon2, you change the schemes list and deprecated="auto" quietly rehashes old passwords as users log in.
The user model
You need somewhere to keep the hashed password. A minimal SQLAlchemy model:
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))Notice the column name. Calling it password invites someone to put the wrong thing in it. hashed_password is small documentation built into the schema.
Pydantic schemas
Two different schemas are useful here. One accepts the raw password during registration. The other never lets it leave the server.
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class UserOut(BaseModel):
id: int
email: EmailStr
model_config = {"from_attributes": True}UserOut has no password field at all. Even if you accidentally return user instead of building a response, the password hash cannot leak through this schema.
The registration route
Now the pieces fit together.
from fastapi import Depends, FastAPI, HTTPException, status
from sqlalchemy.orm import Session
from .database import get_db
from .models import User
from .security import hash_password
from .schemas import UserCreate, UserOut
app = FastAPI()
@app.post(
"/auth/register",
response_model=UserOut,
status_code=status.HTTP_201_CREATED,
)
def register(payload: UserCreate, db: Session = Depends(get_db)):
existing = db.query(User).filter(User.email == payload.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
user = User(
email=payload.email,
hashed_password=hash_password(payload.password),
)
db.add(user)
db.commit()
db.refresh(user)
return userA few small things in there that are easy to skip:
409 Conflictis more accurate than400 Bad Requestfor a duplicate email.- The raw password never touches the model. It is hashed before the
Useris built. - The response model strips the password hash, even though it is on the object.
A word about password strength
Don't go overboard with strength rules. The NIST guidance for years now has been:
| Do | Don't |
|---|---|
| Require a reasonable minimum length (8+ is fine, 12+ is better) | Force a mix of uppercase, lowercase, digits, and symbols |
| Allow long passphrases and spaces | Reject pasted passwords |
| Check against a list of known-leaked passwords if you can | Force quarterly rotation for no reason |
Complex composition rules push people toward Password1!, which is worse than a long phrase. Length and not-already-breached are what actually matter.
Timing attacks (a quick note)
When checking passwords in a login route, always use the library's verify function rather than ==. passlib.verify runs in constant time. A raw string comparison can leak information about how many characters matched based on how long it took to fail. The difference is microseconds, but attackers are patient.
What we deliberately skipped
This doc is on purpose only the storage half of the problem. The login half - receiving credentials, verifying them, and handing back a token - is the subject of the next two docs on OAuth2 and JWTs. Get the storage right first; the rest builds on top of it.
How is this guide?
Last updated on
