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:
- Registration - Create a new user account
- Login - Verify credentials, issue token
- 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
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!)
Benefit: Stateless! Server doesn't store anything.
JWT Structure
A JWT has three parts:
header.payload.signature
Example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcmNoaSIsImV4cCI6MTY3ODkwMTIzNH0.rYVvXZ9f_Q...
Header (base64 encoded):
{
"alg": "HS256",
"typ": "JWT"
}
Payload (base64 encoded):
{
"sub": "archi",
"exp": 1678901234
}
Signature:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
SECRET_KEY
)
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
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
Generate a secure SECRET_KEY:
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
CRITICAL: Never commit .env to Git. Add to .gitignore:
echo ".env" >> .gitignore
Step 3: Password Hashing
Why hash passwords?
Scenario: Your database gets hacked.
If you store plain text:
username: archi
password: securepass123 ← Attacker sees this!
If you hash:
username: archi
hashed_password: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/ ← Useless to attacker!
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)
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
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
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"
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
What can go wrong?
- Token expired → JWTError raised
- Invalid signature → JWTError raised (token was tampered with)
- Missing "sub" → credentials_exception
- 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")
What this does:
- Tells FastAPI where to get tokens from
- Automatically extracts token from
Authorization: Bearer <token>header - Shows "Authorize" button in /docs
- 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
# ...
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")
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")
- 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")
Foreign key:
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
- 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)
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
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 ✅
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
Security checks:
- Verify username doesn't exist - Prevents duplicates
- Hash password before storing - NEVER store plain text!
- 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"}
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")
2. Always verify password:
# Check both conditions together
if not user or not verify_password(credentials.password, user.hashed_password):
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
What Depends(get_current_user) does:
- Extract token from Authorization header
- Decode and validate token
- Look up user in database
- Return user object
- 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
SQL equivalent:
SELECT * FROM tasks WHERE owner_id = current_user.id;
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 = 2Only 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..."
}
2. Login:
POST /auth/login
{
"username": "archi",
"password": "securepass123"
}
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
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!
...
}
4. Try without token (fails):
POST /tasks/
{
"title": "Unauthorized"
}
Response: 401 Unauthorized
{
"detail": "Not authenticated"
}
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"}'
Login:
curl -X POST "http://localhost:8000/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"archi","password":"test1234"}'
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"}'
Using Interactive Docs
- Go to http://localhost:8000/docs
- Click "Authorize" button (🔓 icon)
- Click "POST /auth/login"
- Enter username and password
- Click "Try it out" → "Execute"
- Copy the
access_token - Click "Authorize" button again
- Paste token (will auto-add "Bearer ")
- Click "Authorize"
- Now all endpoints show 🔒 (locked/authorized)
- 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")
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)
)
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!
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()
Mistake 4: Hardcoding SECRET_KEY
# BAD ❌
SECRET_KEY = "my-secret-123" # In code!
# GOOD ✅
SECRET_KEY = os.getenv("SECRET_KEY") # From .env
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)
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)