Security is the most critical foundation of any social media application. In an Instagram clone, our backend handles sensitive credentials, private photos, and personal interactions. A single security flaw can expose millions of accounts to credential stuffing, session hijacking, or account enumeration attacks.
In this tutorial, we will explore the production-grade security architecture we implemented in our backend, covering:
- Modern Password Hashing with Argon2id (OWASP recommended)
- Opaque Bearer Token Sessions with SHA-256 Storage Hashing
- Account Enumeration Defense via Dummy Verifications
- Actor Extraction with FastAPI Dependencies
- Input Protection & Header Sanitization
1. Password Hashing: Why Argon2id?
For years, developers relied on MD5, SHA-256, or bcrypt for password hashing. Today, Argon2id (the winner of the Password Hashing Competition) is the gold standard recommended by OWASP.
Why Argon2id?
- Memory-hard & CPU-intensive: Unlike SHA-256, Argon2id is deliberately expensive to compute, rendering brute-force attacks using GPUs and dedicated ASICs economically infeasible.
- Hybrid resistance: Argon2id combines Argon2d (resists GPU cracking) and Argon2i (resists side-channel and cache-timing attacks).
The Async Thread-Pool Challenge
Because Argon2id is intentionally CPU-intensive, running it directly inside an async function would block Python's event loop, stalling all other users!
To solve this, we offload the hashing computation to a background worker thread using asyncio.to_thread:
import asyncio
from argon2 import PasswordHasher
# Configured according to OWASP guidelines:
# 64 MB memory cost, 3 iterations, 4 parallel threads
_ph = PasswordHasher(
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=32,
salt_len=16,
)
async def hash_password(password: str) -> str:
"""Hashes password in a worker thread to keep the event loop non-blocking."""
return await asyncio.to_thread(_ph.hash, password)
async def verify_password(password: str, hashed_password: str) -> bool:
"""Verifies password safely without stalling concurrent server requests."""
try:
await asyncio.to_thread(_ph.verify, hashed_password, password)
return True
except Exception:
return False
2. Timing Attack Mitigation & Account Enumeration Defense
Have you ever noticed that poorly built websites say:
- "User does not exist" when entering an incorrect email, but
- "Incorrect password" when entering a valid email?
This allows attackers to compile lists of registered users—a vulnerability called Account Enumeration.
Even if you return a generic message like "Invalid credentials", an attacker can measure the server response time:
- Non-existent user: Returns in ~5ms (DB lookup fails immediately).
- Existing user: Takes ~80ms (DB lookup succeeds, then expensive Argon2id hash verification runs).
By measuring response latency, attackers know which accounts exist!
The Solution: Dummy Password Verification
In our backend, if a username or email is not found in the database, we intentionally execute a dummy hash verification against a pre-computed hash. This burns the exact same amount of CPU time as a real verification:
# Pre-computed dummy hash created at server boot
_DUMMY_HASH = _ph.hash("dummy_password_for_timing_mitigation_123")
async def dummy_verify_password(password: str) -> None:
"""
Executes a dummy hash verification to burn approximately equal CPU time
when a user account is not found, mitigating timing enumeration attacks.
"""
try:
await asyncio.to_thread(_ph.verify, _DUMMY_HASH, password)
except Exception:
pass
The Unified Login Handler:
async def authenticate_user(db: AsyncSession, username_or_email: str, password: str):
user = await get_user_by_identifier(db, username_or_email)
if not user:
# User not found: run dummy verification to equalize response timing
await dummy_verify_password(password)
raise HTTPException(status_code=401, detail="Invalid username or password")
# User exists: verify against actual stored hash
is_valid = await verify_password(password, user.hashed_password)
if not is_valid:
raise HTTPException(status_code=401, detail="Invalid username or password")
return user
Whether the user exists or not, the endpoint takes the exact same time to respond!
3. High-Entropy Bearer Tokens & SHA-256 Storage Hashing
For session management, we use cryptographically secure opaque Bearer tokens.
The Golden Security Rule: Never Store Plain Tokens in the Database
If an attacker manages to obtain a read-only SQL injection or a database backup dump, they could use raw stored session tokens to impersonate all logged-in users.
To prevent this, we follow the same pattern used by Git and GitHub:
- Raw Token: Sent only once to the client client-side upon login.
- Token Hash: Only the deterministic SHA-256 hash of the token is stored in the database.
[ Client / Browser ] [ Database ]
| |
|---- Login Credentials -------------------->|
|<--- Raw Token: sf_abc123... (Never stored)-|
| |
| Stores: sha256(raw_token)
| "e3b0c44298fc1c149afbf4c8..."
|
|---- Authorization: Bearer sf_abc123... --->|
Hashes incoming raw token
Matches stored hash in DB!
Token Generation Implementation:
import hashlib
import secrets
from typing import Tuple
def hash_token(raw_token: str) -> str:
"""Computes SHA-256 hash (64 hex characters) for database lookup."""
return hashlib.sha256(raw_token.strip().encode("utf-8")).hexdigest()
def generate_session_token() -> Tuple[str, str]:
"""
Generates a cryptographically random session token (256-bit entropy).
Returns (raw_token, token_hash).
"""
# High-entropy random token with prefix
raw_token = f"sf_{secrets.token_urlsafe(32)}"
token_hash = hash_token(raw_token)
return raw_token, token_hash
4. Actor Extraction via Dependency Injection
In an Instagram backend, every protected endpoint needs to know: "Who is making this request?" (The Actor).
Instead of parsing headers and validating database sessions manually in every endpoint, FastAPI provides an elegant Actor Extraction pattern using Depends:
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
security_scheme = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
"""
Extracts, hashes, and validates Bearer token against active DB sessions.
Returns the authenticated User actor model.
"""
if not credentials or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
# 1. Compute SHA-256 of the presented bearer token
token_hash = hash_token(credentials.credentials)
# 2. Look up the active session in the database
query = (
select(User)
.join(Session, Session.user_id == User.id)
.where(
Session.token_hash == token_hash,
Session.is_revoked == False,
Session.expires_at > func.now()
)
)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired or invalid"
)
return user
How Endpoints Consume the Authenticated Actor:
Notice how clean the route handler becomes:
@router.post("/posts")
async def create_new_post(
post_data: CreatePostSchema,
current_user: User = Depends(get_current_user), # Actor extracted!
db: AsyncSession = Depends(get_db)
):
# current_user is guaranteed to be authenticated and valid
post = Post(
caption=post_data.caption,
image_url=post_data.image_url,
user_id=current_user.id
)
db.add(post)
await db.commit()
return {"message": "Post published", "post_id": post.id}
5. Input Protection & Header Sanitization
Security is not just about passwords; it includes sanitizing every piece of metadata that enters the system.
1. Request ID Sanitization
Every request is tracked across logs using a unique X-Request-Id. If clients send their own request ID, we must sanitize it with a strict Regex whitelist to prevent log-injection or header-injection attacks:
import re
import uuid
# Only allow safe alphanumeric and punctuation characters (1 to 128 characters)
REQUEST_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\.:]{1,128}$")
def sanitize_or_generate_request_id(incoming_header: str | None) -> str:
"""Validates client header against safe ASCII pattern or generates UUID4."""
if incoming_header and REQUEST_ID_PATTERN.match(incoming_header):
return incoming_header
return str(uuid.uuid4())
2. Pydantic Schema Bounds
Every incoming payload is strictly validated using Pydantic:
- Post captions are limited to 2,200 characters (matching Instagram's limit).
- Usernames are restricted to alphanumeric characters and underscores (
^[a-zA-Z0-9_.]+$). - Passwords require minimum length and complexity.
Summary of Security Architecture
| Security Threat | Countermeasure In Our Backend |
|---|---|
| GPU/ASIC Brute-Force | Argon2id memory-hard password hashing (64MB, 3 iterations). |
| Event-Loop Blocking | Worker threads via asyncio.to_thread for CPU-heavy crypto operations. |
| Timing & User Enumeration |
dummy_verify_password equalizes execution timing on 401 errors. |
| DB Session Leaks | Opaque sf_ tokens; only SHA-256 hashes stored in database. |
| Tampered Requests | Type-safe Pydantic input schemas and strict Header Regex sanitization. |
| Session Tracking | Clean get_current_user dependency injection for actor resolution. |
In the next post, we will explore API Architecture & Lifecycle: Middleware, Context Variables, and CORS!
Top comments (0)