FastAPI Password Hashing
Introduction
Password security is one of the most critical aspects of any web application. Storing plain-text passwords in a database is a severe security risk - if your database is compromised, all user accounts are immediately vulnerable. That's where password hashing comes into play.
In this tutorial, we'll explore how to implement secure password hashing in FastAPI applications. We'll use Python's passlib
library, which provides robust password hashing algorithms and is recommended by the FastAPI team.
Understanding Password Hashing
Password hashing is a one-way transformation of a password into a fixed-length string of characters. Unlike encryption, hashing is designed to be irreversible - you cannot convert the hash back to the original password.
Key benefits of password hashing include:
- One-way transformation: Even if attackers gain access to your database, they can't reverse the hashes to reveal original passwords
- Fingerprinting: The same password always produces the same hash (though we'll add "salt" to improve security)
- Fixed output length: Regardless of password length, the hash has a consistent size
Setting Up Your FastAPI Project
Let's start by setting up a basic FastAPI project with the necessary dependencies:
pip install fastapi uvicorn passlib python-multipart bcrypt
Here's what each package does:
fastapi
: Our web frameworkuvicorn
: ASGI server to run our applicationpasslib
: Library for password hashingpython-multipart
: For form data processingbcrypt
: Modern hashing algorithm we'll use with passlib
Creating a Password Hashing Utility
First, let's create a dedicated module for password hashing operations. Create a file named password_utils.py
:
from passlib.context import CryptContext
# Create a password context using bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
"""
Verify a plain password against its hash
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
"""
Generate a hash for the given password
"""
return pwd_context.hash(password)
This module provides two essential functions:
get_password_hash()
: Converts a plain password to a secure hashverify_password()
: Compares a plain password against a stored hash
Implementing User Registration
Now, let's implement a simple user registration system that uses our hashing utilities:
from fastapi import FastAPI, HTTPException, Depends, status
from pydantic import BaseModel
from .password_utils import get_password_hash, verify_password
app = FastAPI()
# Mock database (in a real app, you'd use a proper database)
fake_users_db = {}
class UserCreate(BaseModel):
username: str
password: str
class UserOut(BaseModel):
username: str
@app.post("/register/", response_model=UserOut)
async def register_user(user: UserCreate):
# Check if user already exists
if user.username in fake_users_db:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Hash the password
hashed_password = get_password_hash(user.password)
# Store the user with the hashed password
fake_users_db[user.username] = {
"username": user.username,
"hashed_password": hashed_password
}
return {"username": user.username}
When a user registers, we:
- Check if the username is already taken
- Hash their plain-text password using our utility function
- Store the username and hashed password in our database
- Return the username (never the password or hash) to confirm registration
Implementing User Login
Next, let's implement a login endpoint to authenticate users:
class UserLogin(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str
@app.post("/login/")
async def login_user(user: UserLogin):
# Check if user exists
if user.username not in fake_users_db:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
stored_user = fake_users_db[user.username]
# Verify the password
if not verify_password(user.password, stored_user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# In a real app, you would generate a JWT token here
# For this example, we'll just return a simple message
return {"message": "Login successful"}
During login, we:
- Check if the username exists in our database
- Use
verify_password()
to compare the submitted password with the stored hash - Return a success message (or in a real app, a token) if authentication passes
Understanding bcrypt and Salting
When using bcrypt (via passlib), we don't need to manually handle "salting" - bcrypt does it automatically. A salt is a random value added to the password before hashing to ensure that identical passwords don't produce identical hashes.
With bcrypt, the salt is:
- Automatically generated
- Stored as part of the hash string
- Used during verification without any extra code
This is why we don't need separate salt fields in our database - the bcrypt hash contains everything needed.
Setting Up Password Policies
To enhance security, let's implement some basic password policies:
from pydantic import BaseModel, validator
import re
class UserCreate(BaseModel):
username: str
password: str
@validator('password')
def password_strength(cls, v):
"""Validate password strength"""
if len(v) < 8:
raise ValueError('Password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain an uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain a lowercase letter')
if not re.search(r'[0-9]', v):
raise ValueError('Password must contain a digit')
return v
This validator ensures that passwords:
- Are at least 8 characters long
- Contain at least one uppercase letter
- Contain at least one lowercase letter
- Contain at least one digit
Complete Example Application
Let's put everything together into a complete FastAPI application:
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, validator
from passlib.context import CryptContext
import re
app = FastAPI(title="Password Hashing Demo")
# Password context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Mock database
fake_users_db = {}
# Utility functions
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
# Models
class UserCreate(BaseModel):
username: str
password: str
@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain an uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain a lowercase letter')
if not re.search(r'[0-9]', v):
raise ValueError('Password must contain a digit')
return v
class UserOut(BaseModel):
username: str
class UserLogin(BaseModel):
username: str
password: str
# Routes
@app.post("/register/", response_model=UserOut)
async def register_user(user: UserCreate):
if user.username in fake_users_db:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
hashed_password = get_password_hash(user.password)
fake_users_db[user.username] = {
"username": user.username,
"hashed_password": hashed_password
}
return {"username": user.username}
@app.post("/login/")
async def login_user(user: UserLogin):
if user.username not in fake_users_db:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
stored_user = fake_users_db[user.username]
if not verify_password(user.password, stored_user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return {"message": "Login successful"}
@app.get("/users/")
async def get_users():
# This is just for demonstration - never expose hashed passwords in a real app!
return fake_users_db
You can run this application using Uvicorn:
uvicorn main:app --reload
Testing with the Swagger UI
FastAPI automatically provides a Swagger UI at the /docs
endpoint. Here's how you can test the application:
- Navigate to
http://localhost:8000/docs
- Try registering a user with the
/register
endpoint - Try logging in with the
/login
endpoint - View the stored users (with hashed passwords) using the
/users
endpoint
Best Practices for Password Hashing
Here are some important best practices to follow:
- Never store plain-text passwords - always hash them
- Use modern hashing algorithms - bcrypt, Argon2, or PBKDF2 are recommended (we used bcrypt)
- Include automatic salting - which bcrypt provides
- Implement password policies - as we demonstrated with Pydantic validators
- Rate limit authentication attempts - to prevent brute force attacks
- Use HTTPS - to protect passwords during transmission
- Consider password breached checks - using services like "Have I Been Pwned"
Integrating with a Database
In a real application, you would store user credentials in a database. Here's a quick example using SQLAlchemy:
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Database setup
SQLALCHEMY_DATABASE_URL = "sqlite:///./users.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# User model
class UserDB(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
# Create tables
Base.metadata.create_all(bind=engine)
Then you would modify your routes to use the database:
@app.post("/register/", response_model=UserOut)
async def register_user(user: UserCreate):
db = SessionLocal()
# Check if user exists
db_user = db.query(UserDB).filter(UserDB.username == user.username).first()
if db_user:
db.close()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Create new user
hashed_password = get_password_hash(user.password)
db_user = UserDB(username=user.username, hashed_password=hashed_password)
db.add(db_user)
db.commit()
db.refresh(db_user)
db.close()
return {"username": user.username}
Summary
In this tutorial, you've learned how to:
- Set up password hashing in FastAPI using the
passlib
library - Create utilities for hashing passwords and verifying them
- Implement user registration with secure password storage
- Create a login system that authenticates against hashed passwords
- Add password strength requirements using Pydantic validators
- Apply best practices for password security
Password hashing is a fundamental security practice for any application that handles user authentication. By following the patterns shown in this tutorial, you can ensure that your users' credentials remain secure even if your database is compromised.
Additional Resources
Exercises
- Add Password Reset Functionality: Implement a password reset system that allows users to securely change their passwords.
- Enhance Password Policy: Extend the password validator to check for common passwords or dictionary words.
- Add Multi-Factor Authentication: Research and implement a second factor of authentication beyond passwords.
- Implement Account Lockout: Add functionality to temporarily lock accounts after multiple failed login attempts.
- Password History: Prevent users from reusing their previous passwords by keeping a history of password hashes.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)