Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

Instagram Clone Backend — Part 4: Security & Authentication (Bearer Tokens, Argon2id, Enumeration Defense, Actor Extraction & Input Protection)

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 mil

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:

  1. Modern Password Hashing with Argon2id (OWASP recommended)
  2. Opaque Bearer Token Sessions with SHA-256 Storage Hashing
  3. Account Enumeration Defense via Dummy Verifications
  4. Actor Extraction with FastAPI Dependencies
  5. 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:

  1. Raw Token: Sent only once to the client client-side upon login.
  2. 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!

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.