Stop Leaving Your FastAPI Routes Open — Here's How I Added JWT + OAuth2

When I built RecruitIQ — my AI-powered Applicant Tracking System — one of the first things I realized was:
"Anyone can hit any endpoint. That's a problem."
A recruiter shouldn't access candidate-only data. An unauthenticated user shouldn't post jobs. And absolutely no one should be able to read passwords in plain text.
So I secured the entire API with JWT + OAuth2 — and in this article, I'll show you exactly how I did it, step by step.
By the end, you'll have:
✅ Password hashing with bcrypt
✅ JWT token creation and verification
✅ OAuth2 login endpoint
✅ Protected routes using
Depends()✅ Role-Based Access Control (RBAC) as a bonus
Let's go. 🚀
🧠 Quick Concepts (No Fluff)
What is JWT?
JWT stands for JSON Web Token. It's a compact, self-contained token that looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzYW51QGV4YW1wbGUuY29tIn0.abc123xyz
It has 3 parts separated by dots:
Header — algorithm used (HS256)
Payload — data (user ID, role, expiry)
Signature — verifies the token hasn't been tampered with
The server issues the token on login. The client sends it on every request. The server verifies it — no database lookup needed.
What is OAuth2?
OAuth2 is an authorization framework. FastAPI has built-in support for it via OAuth2PasswordBearer, which tells Swagger UI to expect a Bearer token in the Authorization header.
Together: OAuth2 handles the flow, JWT is the token.
📦 Project Setup
Install Dependencies
pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] python-multipart
| Package | Purpose |
|---|---|
python-jose |
Create & verify JWT tokens |
passlib[bcrypt] |
Hash & verify passwords |
python-multipart |
Required for form-based login |
Folder Structure
app/
├── core/
│ ├── config.py # Secret key, algorithm, token expiry
│ └── security.py # Password hashing + JWT logic
├── api/
│ └── auth.py # Login endpoint
├── models/
│ └── models.py # User model
└── main.py
🔐 Section 1 — Password Hashing with bcrypt
Never store plain text passwords. Ever.
Create app/core/security.py:
from passlib.context import CryptContext
from datetime import datetime, timedelta
from jose import JWTError, jwt
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
hash_password("mypassword") returns something like:
\(2b\)12$KIXQz8v2Jv1...
And verify_password() compares the plain text against the hash — bcrypt handles the salt automatically.
🔑 Section 2 — JWT Token Creation & Verification
In app/core/config.py, define your settings:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SECRET_KEY: str = "your-super-secret-key-change-this"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
settings = Settings()
⚠️ In production, load
SECRET_KEYfrom an environment variable — never hardcode it.
Now add token functions to security.py:
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None
create_access_token({"sub": "user@example.com", "role": "recruiter"}) creates a signed token that expires in 30 minutes.
🚪 Section 3 — OAuth2 Login Endpoint
Create app/api/auth.py:
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from app.core.security import verify_password, create_access_token
router = APIRouter(prefix="/auth", tags=["Auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
# Simulated user DB (replace with real MySQL query)
users_db = {
"sanu@example.com": {
"email": "sanu@example.com",
"hashed_password": "\(2b\)12$...", # bcrypt hash of "password123"
"role": "recruiter"
}
}
@router.post("/login")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = users_db.get(form_data.username)
if not user or not verify_password(form_data.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token({
"sub": user["email"],
"role": user["role"]
})
return {"access_token": token, "token_type": "bearer"}
What's happening here:
OAuth2PasswordRequestFormreadsusernameandpasswordfrom form dataWe verify the password against the hash
On success, we return a JWT token
On failure, we raise a
401 Unauthorized
🛡️ Section 4 — Protecting Routes with Depends()
This is where the magic happens. Add a get_current_user dependency:
from fastapi import Security
from app.core.security import decode_token
def get_current_user(token: str = Depends(oauth2_scheme)):
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
return payload # {"sub": "user@example.com", "role": "recruiter"}
Now protect any route by adding Depends(get_current_user):
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get("/jobs")
def get_jobs(current_user: dict = Depends(get_current_user)):
return {"message": f"Hello {current_user['sub']}, here are the jobs!"}
@router.post("/jobs")
def create_job(current_user: dict = Depends(get_current_user)):
return {"message": "Job created!"}
If someone hits /jobs without a token, FastAPI automatically returns:
{
"detail": "Not authenticated"
}
No extra code needed. That's the beauty of Depends().
🧪 Section 5 — Testing in Swagger UI
FastAPI's built-in Swagger UI (/docs) works perfectly with OAuth2.
Run your app:
uvicorn app.main:app --reloadClick the 🔒 Authorize button
Enter your email and password
Swagger sends the login request and stores the Bearer token automatically
Now all protected routes are unlocked in Swagger!
This is one of my favorite FastAPI features — zero extra setup for interactive auth testing.
👥 Bonus — Role-Based Access Control (RBAC)
In RecruitIQ, recruiters can post jobs but candidates cannot. Here's how I implemented that cleanly:
from fastapi import HTTPException, status
def require_role(required_role: str):
def role_checker(current_user: dict = Depends(get_current_user)):
if current_user.get("role") != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Requires role: {required_role}"
)
return current_user
return role_checker
# Usage
@router.post("/jobs")
def create_job(current_user: dict = Depends(require_role("recruiter"))):
return {"message": "Job posted by recruiter!"}
@router.get("/applications/my")
def my_applications(current_user: dict = Depends(require_role("candidate"))):
return {"message": "Your applications!"}
Now:
A recruiter hitting
/applications/mygets403 ForbiddenA candidate hitting
/jobs(POST) gets403 ForbiddenThe right roles get through ✅
🔒 Security Best Practices
Before you ship to production:
Never hardcode
SECRET_KEY— use environment variables or.envfiles withpython-dotenvUse HTTPS — JWT tokens in plain HTTP can be intercepted
Set a reasonable expiry — 15–60 minutes for access tokens is standard
Use refresh tokens for long sessions (beyond the scope of this article)
Don't store sensitive data in JWT payload — it's base64 encoded, not encrypted
✅ What We Built
Let's recap what you now have:
| Feature | Status |
|---|---|
| Password hashing (bcrypt) | ✅ |
| JWT token creation | ✅ |
| OAuth2 login endpoint | ✅ |
Protected routes with Depends() |
✅ |
| Role-Based Access Control | ✅ |
| Swagger UI integration | ✅ |
This is the exact auth system powering RecruitIQ — my AI-powered ATS built with FastAPI, MySQL, and Groq AI.
🚀 What's Next?
In the next article, I'll show you how I built a Smart Resume Screener using FastAPI + Groq AI (Llama3) — where the API reads a PDF resume and returns an AI-generated score and feedback automatically.
Follow the series: FastAPI & AI — Build Real Projects on my Hashnode blog
Built something with this guide? Drop it in the comments — I'd love to see it!
Connect with me:
🐙 GitHub: github.com/sanu495
💼 LinkedIn: linkedin.com/in/sanoop-sanu658
🌐 Portfolio: sanoop-developer.vercel.app



