Skip to main content

Command Palette

Search for a command to run...

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

Updated
7 min read
Stop Leaving Your FastAPI Routes Open — Here's How I Added JWT + OAuth2
S
Python & FastAPI Backend Developer | Built RecruitIQ, an AI-powered Applicant Tracking System using FastAPI, MySQL, and Groq AI. I build REST APIs, backend systems, and practical AI integrations, and I write about Python, FastAPI, and real-world projects.

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_KEY from 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:

  • OAuth2PasswordRequestForm reads username and password from form data

  • We 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.

  1. Run your app: uvicorn app.main:app --reload

  2. Go to http://localhost:8000/docs

  3. Click the 🔒 Authorize button

  4. Enter your email and password

  5. Swagger sends the login request and stores the Bearer token automatically

  6. 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/my gets 403 Forbidden

  • A candidate hitting /jobs (POST) gets 403 Forbidden

  • The right roles get through ✅


🔒 Security Best Practices

Before you ship to production:

  • Never hardcode SECRET_KEY — use environment variables or .env files with python-dotenv

  • Use 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: