How to Implement Custom User Authentication Flows in FastAPI Boilerplate
The benavlabs/fastapi-boilerplate repository provides a complete JWT-based authentication system built around OAuth2PasswordBearer that you can extend by modifying security.py, adding custom claims, or implementing MFA flows without breaking existing endpoints.
This guide walks through the architecture of the authentication layer in the benavlabs/fastapi-boilerplate repository and demonstrates how to implement custom user authentication flows—from simple role-based claims to multi-factor authentication—while maintaining compatibility with the existing FastAPI structure.
Understanding the Default Authentication Architecture
The boilerplate ships with a stateless JWT implementation that handles password hashing, token creation, and revocation through a centralized security module.
Core Security Components in security.py
The file src/app/core/security.py serves as the central security utility. It contains:
verify_password– Uses bcrypt to compare plain passwords against stored hashesauthenticate_user– Queries the database viacrud_users.getby username or emailcreate_access_tokenandcreate_refresh_token– Encode JWT payloads with expiration claims (exp) andtoken_typedistinctionsverify_token– Decodes tokens, validatestoken_type, and checks the token_blacklist tableblacklist_token– Revokes tokens by inserting them into the blacklist
Login and Logout Endpoints
The authentication endpoints reside in src/app/api/v1/login.py and src/app/api/v1/logout.py:
/login– Accepts username/password, callsauthenticate_user, returns the access token in the JSON body, and sets the refresh token as an HttpOnly cookie/logout– Receives the access token via theAuthorizationheader (handled byoauth2_scheme) and the refresh token from the cookie, then callsblacklist_tokenfor both
Data Layer Components
The user data flow relies on two key files:
src/app/models/user.py– SQLAlchemy model defining fields likehashed_password,is_superuser, and timestampssrc/app/crud/crud_users.py– FastCRUD wrapper providing database operations used byauthenticate_user
Extending the Default Flow for Custom Requirements
Because the architecture isolates security logic in pure functions, you can inject custom behavior without modifying FastAPI core dependencies.
Adding Custom JWT Claims for Role-Based Access
To include roles or permissions in the token payload, modify create_access_token in src/app/core/security.py:
# src/app/core/security.py
from typing import List, Any
from datetime import timedelta
async def create_access_token(
data: dict[str, Any],
expires_delta: timedelta | None = None,
roles: List[str] | None = None,
) -> str:
to_encode = data.copy()
if roles:
to_encode["roles"] = roles # Inject custom claim
# Existing expiration logic
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire, "token_type": "access"})
return jwt.encode(to_encode, SECRET_KEY.get_secret_value(), algorithm=ALGORITHM)
Then update the login endpoint in src/app/api/v1/login.py to pass role data:
# src/app/api/v1/login.py
user_roles = ["admin"] if user["is_superuser"] else ["user"]
access_token = await create_access_token(
data={"sub": user["username"]},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
roles=user_roles,
)
Finally, extend TokenData in src/app/core/schemas.py to include the new field for validation.
Enforcing Email Verification Before Login
To require verified emails, first add the column to the user model in src/app/models/user.py:
# src/app/models/user.py
is_verified: Mapped[bool] = mapped_column(default=False, index=True)
Run the Alembic migration to update the database schema.
Then modify authenticate_user in src/app/core/security.py to check verification status:
# src/app/core/security.py
async def authenticate_user(username_or_email: str, password: str, db: AsyncSession):
# Existing lookup logic via crud_users.get
db_user = await crud_users.get(db=db, username=username_or_email) or \
await crud_users.get(db=db, email=username_or_email)
if not db_user:
return False
# New verification guard
if getattr(db_user, "is_verified", False) is False:
raise UnauthorizedException("Account not verified. Please check your email.")
# Existing password verification
if not verify_password(password, db_user.hashed_password):
return False
return db_user
Implementing Multi-Factor Authentication (MFA)
For MFA, create a new endpoint at src/app/api/v1/mfa.py that validates a temporary token and OTP before issuing the final access token:
# src/app/api/v1/mfa.py
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(tags=["mfa"])
@router.post("/mfa")
async def verify_mfa(
token: str = Body(..., embed=True), # Temporary MFA token
otp: str = Body(..., embed=True),
db: AsyncSession = Depends(async_get_db),
):
# 1. Validate temporary token
mfa_data = await verify_token(token, TokenType.ACCESS, db)
if not mfa_data:
raise UnauthorizedException("Invalid MFA token.")
# 2. Verify OTP (implementation-specific, e.g., TOTP)
if not await check_otp(mfa_data.username_or_email, otp):
raise UnauthorizedException("Invalid OTP.")
# 3. Issue final access token
final_token = await create_access_token(
data={"sub": mfa_data.username_or_email},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": final_token, "token_type": "bearer"}
Modify the standard /login endpoint to issue a temporary MFA token when user.mfa_enabled is True, instead of returning the final access token immediately.
Implementing Refresh Token Rotation
To enhance security by rotating refresh tokens on every use, extend the /refresh endpoint in src/app/api/v1/login.py:
# src/app/api/v1/login.py
@router.post("/refresh")
async def refresh_access_token(
request: Request,
response: Response,
db: AsyncSession = Depends(async_get_db),
):
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise UnauthorizedException("Refresh token missing.")
user_data = await verify_token(refresh_token, TokenType.REFRESH, db)
if not user_data:
raise UnauthorizedException("Invalid refresh token.")
# Rotate: blacklist the old refresh token
await blacklist_token(refresh_token, db)
# Issue new token pair
new_access = await create_access_token(data={"sub": user_data.username_or_email})
new_refresh = await create_refresh_token(data={"sub": user_data.username_or_email})
# Set new refresh token cookie
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=new_refresh,
httponly=True,
secure=True,
samesite="lax",
max_age=max_age,
)
return {"access_token": new_access, "token_type": "bearer"}
Key Files Reference
| File | Purpose | Location |
|---|---|---|
security.py |
JWT creation, verification, password hashing, blacklist handling | src/app/core/security.py |
login.py |
/login and /refresh endpoints |
src/app/api/v1/login.py |
logout.py |
/logout endpoint and token revocation |
src/app/api/v1/logout.py |
crud_users.py |
FastCRUD operations for User model | src/app/crud/crud_users.py |
user.py |
SQLAlchemy User model definition | src/app/models/user.py |
schemas.py |
Pydantic models for tokens and validation | src/app/core/schemas.py |
Summary
- The benavlabs/fastapi-boilerplate provides a complete JWT authentication system using OAuth2PasswordBearer with access tokens, refresh tokens, and a database-backed blacklist for logout functionality.
- All security logic is centralized in
src/app/core/security.py, making it straightforward to extend claims, add verification checks, or swap hashing algorithms without modifying endpoint logic. - Custom flows such as email verification, MFA, and refresh token rotation are implemented by extending the
authenticate_userfunction, creating new endpoints likemfa.py, and modifying the token creation logic while reusing existingverify_tokenandblacklist_tokenutilities.
Frequently Asked Questions
How do I add custom claims to the JWT payload?
Extend the create_access_token function in src/app/core/security.py to accept additional parameters (like roles: List[str]) and inject them into the to_encode dictionary before calling jwt.encode. Update the TokenData schema in src/app/core/schemas.py to include the new fields for proper validation when verifying tokens.
Can I replace bcrypt with Argon2 for password hashing?
Yes. Modify the verify_password and get_password_hash functions in src/app/core/security.py. Replace the existing bcrypt calls with Argon2 implementations (using the argon2-cffi library) while maintaining the same function signatures that accept (plain_password, hashed_password) and (password) respectively. The rest of the authentication flow remains unchanged.
How do I implement refresh token rotation securely?
In the /refresh endpoint located in src/app/api/v1/login.py, call blacklist_token on the incoming refresh token immediately after verifying it with verify_token. Then generate a new access token and a new refresh token using create_access_token and create_refresh_token. Set the new refresh token as an HttpOnly cookie and return the new access token in the JSON response. This ensures each refresh token is used only once.
Where should I add email verification checks?
Add the verification logic to the authenticate_user function in src/app/core/security.py after retrieving the user from the database but before verifying the password. Check the is_verified attribute (which you must add to the User model in src/app/models/user.py and migrate via Alembic). If is_verified is False, raise an UnauthorizedException with a message indicating the account requires verification.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →