DEV Community

archi-jain
archi-jain

Posted on

Day 3/100: Adding JWT Authentication - Secure APIs with FastAPI

Part of my 100 Days of Code journey. Today we go from single-user to multi-user with proper security.

The Challenge

Add JWT-based authentication to the Task Management API, enabling user registration, login, and secure task isolation.

The Problem: Yesterday's API works great for one person. But what if multiple people use it? How do we know who owns which tasks? How do we keep data private?

The Solution: Implement industry-standard JWT authentication with password hashing, user management, and authorization.

Why Authentication Matters

Let me tell you what happens without authentication:

If you build APIs without authentication, you don't really have an application.

You have an open database with HTTP access.

Imagine a production task manager where:

User A could see User B's tasks.

User B could delete User A's tasks.

That would be chaos.

This isn't a feature. It's a security nightmare.

Today, we fix all of this.

What We're Building

Authentication has three parts:

  1. Registration - Create a new user account
  2. Login - Verify credentials, issue token
  3. Authorization - Validate token, restrict access

Plus security features:

  • Password hashing (never store plain text!)
  • JWT tokens (stateless authentication)
  • User isolation (can't see others' data)

Understanding JWT

JWT (JSON Web Token) is how modern APIs handle authentication.

Traditional Session-Based Auth:

1. User logs in
2. Server creates session, stores in database
3. Server sends session ID to user
4. User sends session ID with each request
5. Server looks up session in database
Enter fullscreen mode Exit fullscreen mode

Problem: Server must store sessions. Doesn't scale well.

JWT Token-Based Auth:

1. User logs in
2. Server creates JWT (signed with secret)
3. Server sends JWT to user
4. User sends JWT with each request
5. Server verifies signature (no database lookup!)
Enter fullscreen mode Exit fullscreen mode

Benefit: Stateless! Server doesn't store anything.

JWT Structure

A JWT has three parts:

header.payload.signature

Example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcmNoaSIsImV4cCI6MTY3ODkwMTIzNH0.rYVvXZ9f_Q...
Enter fullscreen mode Exit fullscreen mode

Header (base64 encoded):

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

Payload (base64 encoded):

{
  "sub": "archi",
  "exp": 1678901234
}
Enter fullscreen mode Exit fullscreen mode

Signature:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  SECRET_KEY
)
Enter fullscreen mode Exit fullscreen mode

Key point: Anyone can decode header and payload (they're just base64). But only someone with the SECRET_KEY can create a valid signature.

This means:

  • ✅ Server can verify token wasn't tampered with
  • ✅ No database lookup needed
  • ✅ Token is self-contained

Step-by-Step Implementation

Step 1: Installing Dependencies

pip install python-jose[cryptography] passlib[bcrypt] python-multipart
Enter fullscreen mode Exit fullscreen mode

Why these packages?

  • python-jose: JWT creation and validation
  • passlib[bcrypt]: Password hashing (bcrypt algorithm)
  • python-multipart: Required for OAuth2 forms

Step 2: Environment Configuration

Create .env:

SECRET_KEY=your-super-secret-key-min-32-characters-long
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
Enter fullscreen mode Exit fullscreen mode

Generate a secure SECRET_KEY:

python3 -c "import secrets; print(secrets.token_urlsafe(32))"
Enter fullscreen mode Exit fullscreen mode

CRITICAL: Never commit .env to Git. Add to .gitignore:

echo ".env" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

Step 3: Password Hashing

Why hash passwords?

Scenario: Your database gets hacked.

If you store plain text:

username: archi
password: securepass123  ← Attacker sees this!
Enter fullscreen mode Exit fullscreen mode

If you hash:

username: archi
hashed_password: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/  ← Useless to attacker!
Enter fullscreen mode Exit fullscreen mode

Create auth.py:

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def get_password_hash(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)
Enter fullscreen mode Exit fullscreen mode

How bcrypt works:

# Hashing (when user registers)
password = "securepass123"
hashed = get_password_hash(password)
# Result: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/...

# Verification (when user logs in)
entered_password = "securepass123"
is_valid = verify_password(entered_password, hashed)
# Result: True

# Wrong password
wrong_password = "wrongpass"
is_valid = verify_password(wrong_password, hashed)
# Result: False
Enter fullscreen mode Exit fullscreen mode

Bcrypt automatically:

  • Generates random salt (prevents rainbow table attacks)
  • Uses slow algorithm (prevents brute force)
  • Includes salt in output (no need to store separately)

Step 4: JWT Token Creation

from datetime import datetime, timedelta
from jose import jwt
import os

SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token(data: dict):
    to_encode = data.copy()

    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})

    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

    return encoded_jwt
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

# Input
data = {"sub": "archi"}

# Add expiration
to_encode = {"sub": "archi", "exp": 1678901234}

# Encode with secret
token = jwt.encode(to_encode, SECRET_KEY, algorithm="HS256")

# Result
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcmNoaSIsImV4cCI6MTY3ODkwMTIzNH0.signature"
Enter fullscreen mode Exit fullscreen mode

What's "sub"?

  • JWT standard claim
  • "Subject" - who the token is about
  • We use username as the subject

Step 5: Token Validation

from jose import jwt, JWTError
from fastapi import HTTPException, status

def get_current_user(token: str, db: Session):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")

        if username is None:
            raise credentials_exception

    except JWTError:
        raise credentials_exception

    user = db.query(User).filter(User.username == username).first()

    if user is None:
        raise credentials_exception

    return user
Enter fullscreen mode Exit fullscreen mode

What can go wrong?

  1. Token expired → JWTError raised
  2. Invalid signature → JWTError raised (token was tampered with)
  3. Missing "sub" → credentials_exception
  4. User doesn't exist → credentials_exception

All result in 401 Unauthorized - never tell attackers which part failed!

Step 6: OAuth2 Password Bearer

from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
Enter fullscreen mode Exit fullscreen mode

What this does:

  1. Tells FastAPI where to get tokens from
  2. Automatically extracts token from Authorization: Bearer <token> header
  3. Shows "Authorize" button in /docs
  4. Returns 401 if token missing

Using it:

def get_current_user(
    token: str = Depends(oauth2_scheme),  # Auto-extracts token!
    db: Session = Depends(get_db)
):
    # token is already extracted from Authorization header
    # ...
Enter fullscreen mode Exit fullscreen mode

Step 7: User Model

Update models.py:

from sqlalchemy import Column, String, Boolean, ForeignKey
from sqlalchemy.orm import relationship

class User(Base):
    __tablename__ = "users"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    username = Column(String(50), unique=True, nullable=False, index=True)
    email = Column(String(100), unique=True, nullable=False, index=True)
    hashed_password = Column(String(255), nullable=False)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    # Relationship
    tasks = relationship("Task", back_populates="owner", cascade="all, delete-orphan")
Enter fullscreen mode Exit fullscreen mode

Key fields:

  • username: Must be unique (for login)
  • email: Must be unique (for verification)
  • hashed_password: NEVER plain text!
  • is_active: Allows disabling accounts
  • index=True: Fast lookups (we search by username often)

Relationship:

tasks = relationship("Task", back_populates="owner", cascade="all, delete-orphan")
Enter fullscreen mode Exit fullscreen mode
  • User can have many tasks
  • If user deleted, delete their tasks too (cascade)

Step 8: Updated Task Model

class Task(Base):
    __tablename__ = "tasks"

    # ... existing fields ...

    # NEW: Foreign key
    owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)

    # NEW: Relationship
    owner = relationship("User", back_populates="tasks")
Enter fullscreen mode Exit fullscreen mode

Foreign key:

owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
Enter fullscreen mode Exit fullscreen mode
  • Links task to a user
  • ondelete="CASCADE": If user deleted, delete task
  • nullable=False: Every task MUST have an owner

Database visualization:

users table:
id | username | email | hashed_password
1  | archi    | ...   | $2b$12...

tasks table:
id | title        | owner_id (FK)
1  | Learn JWT    | 1 (points to archi)
2  | Build API    | 1 (points to archi)
Enter fullscreen mode Exit fullscreen mode

Step 9: Pydantic Schemas

Update schemas.py:

from pydantic import BaseModel, EmailStr

class UserCreate(BaseModel):
    username: str
    email: EmailStr
    password: str

class UserResponse(BaseModel):
    id: UUID
    username: str
    email: str
    is_active: bool
    created_at: datetime
    # NO PASSWORD! Never expose hashes

    class Config:
        from_attributes = True

class Token(BaseModel):
    access_token: str
    token_type: str
Enter fullscreen mode Exit fullscreen mode

Critical: UserResponse excludes password!

# BAD - exposes password hash
class UserResponse(BaseModel):
    username: str
    hashed_password: str  # ❌ NEVER!

# GOOD
class UserResponse(BaseModel):
    username: str
    # No password field ✅
Enter fullscreen mode Exit fullscreen mode

Step 10: Registration Endpoint

Create routers/auth_router.py:

@router.post("/register", response_model=schemas.UserResponse)
def register(user: schemas.UserCreate, db: Session = Depends(get_db)):
    # Check if username exists
    existing = db.query(models.User).filter(
        models.User.username == user.username
    ).first()

    if existing:
        raise HTTPException(status_code=400, detail="Username already exists")

    # Create user with hashed password
    new_user = models.User(
        username=user.username,
        email=user.email,
        hashed_password=get_password_hash(user.password)  # Hash it!
    )

    db.add(new_user)
    db.commit()
    db.refresh(new_user)

    return new_user
Enter fullscreen mode Exit fullscreen mode

Security checks:

  1. Verify username doesn't exist - Prevents duplicates
  2. Hash password before storing - NEVER store plain text!
  3. Return user without password - UserResponse excludes it

Step 11: Login Endpoint

@router.post("/login", response_model=schemas.Token)
def login(credentials: schemas.UserLogin, db: Session = Depends(get_db)):
    # Find user
    user = db.query(models.User).filter(
        models.User.username == credentials.username
    ).first()

    # Verify user exists AND password correct
    if not user or not verify_password(credentials.password, user.hashed_password):
        raise HTTPException(status_code=401, detail="Invalid credentials")

    # Create token
    token = create_access_token({"sub": user.username})

    return {"access_token": token, "token_type": "bearer"}
Enter fullscreen mode Exit fullscreen mode

Security best practices:

1. Generic error messages:

# BAD - tells attacker what's wrong
if not user:
    raise HTTPException(detail="Username not found")
if not verify_password(...):
    raise HTTPException(detail="Wrong password")

# GOOD - same error for both
if not user or not verify_password(...):
    raise HTTPException(detail="Invalid credentials")
Enter fullscreen mode Exit fullscreen mode

2. Always verify password:

# Check both conditions together
if not user or not verify_password(credentials.password, user.hashed_password):
Enter fullscreen mode Exit fullscreen mode

This prevents timing attacks (where attacker can tell if username exists based on response time).

Step 12: Protected Routes

Update routers/tasks_router.py:

@router.post("/", response_model=schemas.TaskResponse)
def create_task(
    task: schemas.TaskCreate,
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user)  # 🔒 Requires auth!
):
    new_task = models.Task(
        title=task.title,
        description=task.description,
        owner_id=current_user.id  # Assign to current user
    )

    db.add(new_task)
    db.commit()
    db.refresh(new_task)

    return new_task
Enter fullscreen mode Exit fullscreen mode

What Depends(get_current_user) does:

  1. Extract token from Authorization header
  2. Decode and validate token
  3. Look up user in database
  4. Return user object
  5. If any step fails → 401 Unauthorized

User isolation:

@router.get("/", response_model=List[schemas.TaskResponse])
def get_tasks(
    db: Session = Depends(get_db),
    current_user: models.User = Depends(get_current_user)
):
    # Only return current user's tasks!
    tasks = db.query(models.Task).filter(
        models.Task.owner_id == current_user.id
    ).all()

    return tasks
Enter fullscreen mode Exit fullscreen mode

SQL equivalent:

SELECT * FROM tasks WHERE owner_id = current_user.id;
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • Alice (user id=1) calls GET /tasks
  • Query: WHERE owner_id = 1
  • Only Alice's tasks returned

  • Bob (user id=2) calls GET /tasks

  • Query: WHERE owner_id = 2

  • Only Bob's tasks returned

Perfect isolation!

Step 13: Complete Flow Example

User journey:

1. Register:

POST /auth/register
{
  "username": "archi",
  "email": "archi@example.com",
  "password": "securepass123"
}

Response:
{
  "id": "uuid-here",
  "username": "archi",
  "email": "archi@example.com",
  "is_active": true,
  "created_at": "2026-03-05T..."
}
Enter fullscreen mode Exit fullscreen mode

2. Login:

POST /auth/login
{
  "username": "archi",
  "password": "securepass123"
}

Response:
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}
Enter fullscreen mode Exit fullscreen mode

3. Create task (with token):

POST /tasks/
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
{
  "title": "My Task",
  "description": "Only I can see this"
}

Response:
{
  "id": "task-uuid",
  "title": "My Task",
  "owner_id": "archi-uuid",  ← Automatically set!
  ...
}
Enter fullscreen mode Exit fullscreen mode

4. Try without token (fails):

POST /tasks/
{
  "title": "Unauthorized"
}

Response: 401 Unauthorized
{
  "detail": "Not authenticated"
}
Enter fullscreen mode Exit fullscreen mode

Testing the Authentication

Using cURL

Register:

curl -X POST "http://localhost:8000/auth/register" \
  -H "Content-Type: application/json" \
  -d '{"username":"archi","email":"archi@example.com","password":"test1234"}'
Enter fullscreen mode Exit fullscreen mode

Login:

curl -X POST "http://localhost:8000/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"archi","password":"test1234"}'
Enter fullscreen mode Exit fullscreen mode

Copy the access_token from response.

Create task with token:

curl -X POST "http://localhost:8000/tasks/" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -d '{"title":"Authenticated Task","description":"With JWT"}'
Enter fullscreen mode Exit fullscreen mode

Using Interactive Docs

  1. Go to http://localhost:8000/docs
  2. Click "Authorize" button (🔓 icon)
  3. Click "POST /auth/login"
  4. Enter username and password
  5. Click "Try it out" → "Execute"
  6. Copy the access_token
  7. Click "Authorize" button again
  8. Paste token (will auto-add "Bearer ")
  9. Click "Authorize"
  10. Now all endpoints show 🔒 (locked/authorized)
  11. Try creating tasks - they'll be yours!

Security Considerations

What We Did Right

Password hashing - Never store plain text

Token expiration - Tokens expire after 30 min

Generic errors - Don't leak info to attackers

User isolation - Can't access others' data

Environment variables - Secrets not in code

What Could Be Improved (Future)

1. Refresh tokens:

  • Access token expires quickly (30 min)
  • Refresh token lasts longer (7 days)
  • Use refresh to get new access token

2. Password strength validation:

import re

def validate_password(password: str):
    if len(password) < 8:
        raise ValueError("Too short")
    if not re.search(r"[A-Z]", password):
        raise ValueError("Need uppercase")
    if not re.search(r"[0-9]", password):
        raise ValueError("Need number")
Enter fullscreen mode Exit fullscreen mode

3. Rate limiting:

  • Prevent brute force attacks
  • Limit login attempts per IP
  • Lock account after failures

4. Email verification:

  • Send confirmation email
  • User must verify before login

5. Two-factor authentication (2FA):

  • SMS or authenticator app
  • Extra security layer

Common Mistakes I Avoided

Mistake 1: Storing Plain Text Passwords

# NEVER DO THIS ❌
new_user = User(
    username=username,
    password=password  # Plain text!
)

# ALWAYS DO THIS ✅
new_user = User(
    username=username,
    hashed_password=get_password_hash(password)
)
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Exposing Passwords in API

# BAD ❌
class UserResponse(BaseModel):
    username: str
    hashed_password: str  # Exposed!

# GOOD ✅
class UserResponse(BaseModel):
    username: str
    # No password field!
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Not Checking Task Ownership

# BAD - Anyone can modify any task ❌
task = db.query(Task).filter(Task.id == task_id).first()

# GOOD - Only owner can modify ✅
task = db.query(Task).filter(
    Task.id == task_id,
    Task.owner_id == current_user.id
).first()
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Hardcoding SECRET_KEY

# BAD ❌
SECRET_KEY = "my-secret-123"  # In code!

# GOOD ✅
SECRET_KEY = os.getenv("SECRET_KEY")  # From .env
Enter fullscreen mode Exit fullscreen mode

What I Learned

Technical Skills

✅ JWT token creation and validation

✅ Password hashing with bcrypt

✅ OAuth2 bearer token authentication

✅ FastAPI security dependencies

✅ SQLAlchemy relationships (one-to-many)

✅ User isolation and authorization

Conceptual Understanding

✅ Authentication vs Authorization

✅ Stateless vs Stateful sessions

✅ Token-based auth advantages

✅ Why never store plain text passwords

✅ Salt and hashing concepts

✅ Security best practices

Real-World Patterns

✅ Multi-user application architecture

✅ Router organization and modularity

✅ Environment-based configuration

✅ Error handling that doesn't leak info

The "Aha!" Moments

1. JWT is self-contained
No database lookup needed to validate! The signature proves authenticity.

2. Bcrypt handles salt automatically
Don't need to generate/store salt separately. It's in the hash!

3. Dependency injection is powerful

current_user: User = Depends(get_current_user)
Enter fullscreen mode Exit fullscreen mode

One line gets authenticated user in every route!

4. Relationships simplify queries
Instead of manual joins, just user.tasks or task.owner.

Next Steps

Next improvements I want to add:

  • Refresh tokens
  • Pagination for tasks
  • Password strength validation
  • Email verification
  • Role-based authorization

But today? Today I celebrate building a secure, multi-user API that could actually go to production! 🎉

Resources That Helped

Full Code

Complete code on GitHub:
100-days-of-python/days/003


Time Investment: 3 hours

Knowledge Gained: Production authentication skills

Feeling: Like I can build secure systems 🔐

Day 3/100 complete!

Tomorrow: Advanced features (filtering, search, pagination)


Following my journey?

📝 Blog | 🐦 Twitter | 💼 LinkedIn

Tags: #100DaysOfCode #Python #FastAPI #JWT #Authentication #Security #BackendDevelopment #OAuth2 #PasswordHashing #LearningInPublic

Top comments (0)