Building with AI – A Developer's Diary

Starlette Day 4 — Auth, Middleware, and CORS

Add token-based authentication, custom request logging, and CORS support so your API is ready for a real browser or mobile frontend.

NoCo Interactive • Python · Starlette · Auth · Middleware • 20–25 minute read •

Through Day 3 your API can create, read, update, and delete tasks from a real database. Any caller can do any of those things. Today you change that. By the end of this session every route will require a valid token, unauthenticated requests will get a proper 401, browser frontends will be allowed in via CORS, and every request and response will be logged automatically—without touching individual route handlers.

This is the day your app starts looking like something a real frontend can actually use. A mobile or web client needs to identify itself, and your backend needs to decide whether to let it through. Starlette has first-party support for all of this through its middleware and authentication systems—no extra auth packages required.


Before you start — activate your virtual environment

Open a fresh terminal, navigate to your project, and activate the environment before running any commands:

Shell
cd starlette-day1
source venv/bin/activate   # Mac / Linux
# venv\Scripts\activate    # Windows

Your prompt should show (venv). No new packages are required today—Starlette's authentication and CORS middleware are built into the framework you already installed on Day 1.

The mental model — the middleware stack

Before writing any code, it's worth being clear on what middleware actually is and what order things run in. Every incoming request travels through a stack of layers before it reaches your route handler. Every outgoing response travels back through those same layers in reverse.

Shell
Incoming request
   ↓
CORS middleware         (adds cross-origin headers)
   ↓
Logging middleware      (records the start of the request)
   ↓
Auth middleware         (resolves request.user and request.auth)
   ↓
Route handler           (your business logic)
   ↓
Auth middleware         (passes response through)
   ↓
Logging middleware      (records timing and status)
   ↓
CORS middleware         (passes response through)
   ↓
Response to client

Order matters. CORS goes first because browsers send a preflight request before the real one—if CORS headers aren't added before auth runs, a browser might get a 401 with no CORS headers and interpret it as a network error rather than an auth failure. Logging wraps auth so you can see what the authenticated result was. Auth middleware resolves the user before your handler runs, so request.user is ready when you need it.

Update the project structure

Add two new modules—one for auth, one for middleware:

Shell
mkdir app/auth
mkdir app/middleware
touch app/auth/__init__.py
touch app/auth/backend.py
touch app/middleware/__init__.py
touch app/middleware/logging.py

Your full structure now looks like this:

Shell
starlette-day1/
├── venv/
├── alembic/
├── alembic.ini
├── app.db
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── database.py
│   ├── auth/                ← new
│   │   ├── __init__.py
│   │   └── backend.py
│   ├── middleware/          ← new
│   │   ├── __init__.py
│   │   └── logging.py
│   ├── models/
│   │   ├── __init__.py
│   │   ├── task.py
│   │   └── note.py
│   ├── routes/
│   │   ├── __init__.py
│   │   └── tasks.py
│   └── services/
│       ├── __init__.py
│       └── task_service.py

Build the auth backend

Starlette's authentication system is built around a custom AuthenticationBackend class. You implement one method—authenticate—and Starlette calls it on every request before your handler runs. The result populates request.user and request.auth.

For Day 4 we'll use a fixed bearer token. This is intentionally simple—the goal is to understand the auth pipeline before layering in JWTs or sessions on a later day.

Create app/auth/backend.py:

Python
from starlette.authentication import (
    AuthCredentials,
    AuthenticationBackend,
    AuthenticationError,
    SimpleUser,
)


class BasicTokenAuthBackend(AuthenticationBackend):
    async def authenticate(self, conn):
        auth_header = conn.headers.get("Authorization")

        if not auth_header:
            return None

        try:
            scheme, token = auth_header.split(" ", 1)
        except ValueError:
            raise AuthenticationError("Invalid authorization header format")

        if scheme.lower() != "bearer":
            raise AuthenticationError("Unsupported auth scheme")

        # Demo token — replace with real token validation on a later day
        if token == "dev-secret-token":
            return AuthCredentials(["authenticated"]), SimpleUser("dev")

        raise AuthenticationError("Invalid token")

The authenticate method has three possible outcomes, and understanding the distinction is important:

  • Return None — no auth header was sent. The request proceeds as anonymous. request.user.is_authenticated will be False. This is not an error; it's a valid unauthenticated request.
  • Return a tuple of (AuthCredentials, user) — auth succeeded. request.user becomes your user object, and request.auth.scopes contains the credential strings you provided.
  • Raise AuthenticationError — auth input was malformed or the token was invalid. Starlette catches this and calls your on_error handler (configured in a moment).
Two different failure cases: A missing header is anonymous (return None). A bad token is an error (raise AuthenticationError). Your route layer handles the anonymous case by checking request.user.is_authenticated. The middleware handles the error case by calling on_error before your route ever runs.

Add custom logging middleware

Create app/middleware/logging.py. This middleware runs around every request—it prints before calling the next layer and prints again after the response comes back:

Python
import time
from starlette.middleware.base import BaseHTTPMiddleware


class LoggingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        start_time = time.time()

        print(f"[REQUEST]  {request.method} {request.url.path}")

        response = await call_next(request)

        duration = time.time() - start_time
        print(
            f"[RESPONSE] {request.method} {request.url.path} "
            f"status={response.status_code} duration={duration:.4f}s"
        )

        return response

call_next(request) is the key line—it passes the request to the next middleware in the stack (or to the route handler if this is the innermost middleware). Everything before that line runs on the way in. Everything after runs on the way out. That's the entire middleware pattern.

This is also the right place to later add structured JSON logging, request IDs, timing metrics, or audit trails—all without touching individual route handlers.

Wire everything into main.py

Replace app/main.py with the final version. This is where the middleware stack is assembled and attached to the app:

Python
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse

from app.auth.backend import BasicTokenAuthBackend
from app.middleware.logging import LoggingMiddleware
from app.routes.tasks import routes as task_routes


def on_auth_error(request, exc):
    return JSONResponse({"error": str(exc)}, status_code=401)


middleware = [
    Middleware(
        CORSMiddleware,
        allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
        allow_methods=["*"],
        allow_headers=["*"],
    ),
    Middleware(LoggingMiddleware),
    Middleware(
        AuthenticationMiddleware,
        backend=BasicTokenAuthBackend(),
        on_error=on_auth_error,
    ),
]

app = Starlette(
    debug=True,
    routes=task_routes,
    middleware=middleware,
)

The on_auth_error function is called by AuthenticationMiddleware whenever your backend raises AuthenticationError. Without it, Starlette returns a plain-text 400 response by default—not what you want in a JSON API. This ensures auth errors always return JSON, consistent with every other error in the app.

What CORS actually does

CORS is a browser security rule, not a server one. When a browser-based frontend running on http://localhost:3000 makes a request to your API at http://127.0.0.1:8000, the browser sees two different origins (different host or port). Before sending the real request, the browser sends a preflight OPTIONS request asking: "is this API willing to accept requests from my origin?" If the server doesn't reply with the right headers, the browser blocks the response—even if the server processed it fine.

CORSMiddleware handles all of this automatically. The allow_origins list is your explicit allowlist for development. In production you'd replace it with your real domain names rather than localhost addresses.

Mobile apps and CORS: Native iOS and Android HTTP clients don't enforce browser CORS rules, so CORS isn't a concern for purely mobile backends. If you're building both a mobile app and a web frontend against the same API—which is common—you need CORS configured correctly for the web client. Better to have it in place from the start.

Protect your routes

Now update app/routes/tasks.py. With AuthenticationMiddleware installed, request.user and request.auth are available in every handler. A helper function at the top of the file handles the auth check cleanly without repeating the same conditional in every endpoint:

Python
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route

from app.database import SessionLocal
from app.services import task_service


def require_authentication(request):
    if not request.user.is_authenticated:
        return JSONResponse({"error": "Authentication required"}, status_code=401)
    return None


async def health_check(request):
    return JSONResponse({"message": "API is running"})


async def get_tasks(request):
    auth_error = require_authentication(request)
    if auth_error:
        return auth_error

    with SessionLocal() as db:
        tasks = task_service.get_all_tasks(db)
        return JSONResponse({
            "user": request.user.display_name,
            "items": tasks,
        })


async def get_task(request):
    auth_error = require_authentication(request)
    if auth_error:
        return auth_error

    task_id = int(request.path_params["id"])

    with SessionLocal() as db:
        task = task_service.get_task(db, task_id)

        if not task:
            return JSONResponse({"error": "Task not found"}, status_code=404)

        return JSONResponse(task)


async def create_task(request: Request):
    auth_error = require_authentication(request)
    if auth_error:
        return auth_error

    data = await request.json()
    title = data.get("title")

    if not title:
        return JSONResponse({"error": "Title is required"}, status_code=400)

    with SessionLocal() as db:
        task = task_service.create_task(db, title)
        return JSONResponse(task, status_code=201)


async def update_task(request: Request):
    auth_error = require_authentication(request)
    if auth_error:
        return auth_error

    task_id = int(request.path_params["id"])
    data = await request.json()
    title = data.get("title")

    if not title:
        return JSONResponse({"error": "Title is required"}, status_code=400)

    with SessionLocal() as db:
        task = task_service.update_task(db, task_id, title)

        if not task:
            return JSONResponse({"error": "Task not found"}, status_code=404)

        return JSONResponse(task)


async def delete_task(request):
    auth_error = require_authentication(request)
    if auth_error:
        return auth_error

    task_id = int(request.path_params["id"])

    with SessionLocal() as db:
        deleted = task_service.delete_task(db, task_id)

        if not deleted:
            return JSONResponse({"error": "Task not found"}, status_code=404)

        return JSONResponse({"message": "Deleted"})


routes = [
    Route("/", health_check),
    Route("/tasks", get_tasks),
    Route("/tasks", create_task, methods=["POST"]),
    Route("/tasks/{id:int}", get_task),
    Route("/tasks/{id:int}", update_task, methods=["PUT"]),
    Route("/tasks/{id:int}", delete_task, methods=["DELETE"]),
]

A few things to notice:

  • health_check has no auth guard. One public endpoint lets you verify the server is up without needing a token. Useful for load balancers, uptime monitors, and debugging.
  • get_tasks includes request.user.display_name in the response. This is the only endpoint that does—it's there to demonstrate that the authenticated user is available inside the handler. In a real app you'd use request.user to scope queries to the current user's data.
  • require_authentication checks request.user.is_authenticated. This is distinct from the on_auth_error path in main.py. Error handler runs when auth input is malformed or the token is outright wrong. The require_authentication helper runs when there was simply no auth header—an anonymous request trying to reach a protected route.
Starlette doesn't have built-in route decorators for auth the way some frameworks do. The require_authentication helper pattern is idiomatic Starlette—a plain function you call at the top of each handler that needs it.

Run the app

Shell
uvicorn app.main:app --reload

You should see Uvicorn start normally. The middleware stack loads silently—you won't see confirmation of that in the terminal, but you'll see logging output as soon as the first request comes in.

Test the auth flow

Public health check — no token needed

Shell
curl http://127.0.0.1:8000/

Expected: {"message": "API is running"}. Check your terminal—you'll see the logging middleware output for this request even though no auth was involved.

Protected route without a token

Shell
curl http://127.0.0.1:8000/tasks

Expected: {"error": "Authentication required"} with a 401 status. No Authorization header was sent, so the backend returned None, request.user.is_authenticated is False, and require_authentication stopped the request.

Protected route with a bad token

Shell
curl http://127.0.0.1:8000/tasks \
  -H "Authorization: Bearer wrong-token"

Expected: {"error": "Invalid token"} with a 401. This time the header was present and well-formed, but the token didn't match—the backend raised AuthenticationError, and on_auth_error handled it before the route ran at all.

Protected route with a valid token

Shell
curl http://127.0.0.1:8000/tasks \
  -H "Authorization: Bearer dev-secret-token"

Expected: a JSON object with "user": "dev" and an "items" array containing your tasks. You're now authenticated.

Test CRUD with auth

Every write operation now requires the Authorization header. Add it to every curl command going forward:

Create a task

Shell
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer dev-secret-token" \
  -d '{"title": "Protected task"}'

Update a task

Shell
curl -X PUT http://127.0.0.1:8000/tasks/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer dev-secret-token" \
  -d '{"title": "Updated protected task"}'

Delete a task

Shell
curl -X DELETE http://127.0.0.1:8000/tasks/1 \
  -H "Authorization: Bearer dev-secret-token"

Each of these should work with the token and return a 401 without it. That's the auth layer behaving correctly.

How this maps to a real mobile or web backend

The hardcoded token you're using today is a stand-in for a much more common real-world flow:

  1. User signs in through a login endpoint
  2. Backend issues a signed token (JWT) or creates a server-side session
  3. Frontend stores it (local storage, secure cookie, keychain)
  4. Frontend sends Authorization: Bearer <token> on every subsequent request
  5. Auth middleware validates the token and loads the user
  6. Route handlers use request.user to scope queries and check permissions

The structure you built today—a custom AuthenticationBackend, AuthenticationMiddleware, and request.user checks in handlers—is exactly what you'd keep when moving to JWTs or OAuth. You'd replace the token validation logic inside backend.py, but the rest of the app stays the same.

Common mistakes

Using allow_origins=["*"] with credentials. Wildcard origins work for fully public APIs, but browsers block credentialed requests (those with cookies or Authorization headers) to wildcard origins. Start with an explicit allowlist and keep it that way.

Treating the hardcoded token as production auth. This is a learning scaffold. A real backend uses signed JWTs with expiry, server-side sessions, or an external identity provider. The pipeline you built today is production-shaped; the token validation inside backend.py is not.

Putting business logic inside middleware. Middleware is for cross-cutting concerns: logging, auth resolution, CORS headers, rate limiting. Database queries and application logic belong in routes and services. If you find yourself importing SessionLocal into a middleware file, stop and reconsider.

Forgetting that auth middleware runs before route logic. When AuthenticationError is raised, on_error is called immediately and the route handler never runs. This is expected—the middleware diagram at the top of this article shows why. If a route that should run isn't running, check what auth errors might be happening upstream.

Getting the middleware order wrong. The order in the middleware list is the order requests pass through them. CORS must be first. If auth middleware runs before CORS middleware, a browser preflight that gets a 401 may arrive with no CORS headers, and the browser interprets it as a network failure—which is much harder to debug than a clear 401.

What you actually learned

Three major backend concepts clicked into place today:

  • Middleware wraps your entire request/response lifecycle. Code before call_next() runs on the way in; code after runs on the way out. The order of middleware in the stack determines the order of execution.
  • Starlette's auth system is two things working together: a custom AuthenticationBackend that resolves who's calling, and AuthenticationMiddleware that installs it. Once installed, request.user and request.auth are available everywhere. Your routes check them—they don't do the resolution themselves.
  • CORS is a browser-enforced rule about which origins can call your API. CORSMiddleware handles the headers. Your job is to maintain an accurate allowlist.

Day 5 preview — Background tasks, WebSockets, and async HTTP

On Day 5, the app learns to do work after the response is sent. You'll attach background tasks to responses so external syncs don't make callers wait, make async outbound HTTP calls using HTTPX, and add WebSocket support so connected clients receive live updates when background jobs complete. The auth and middleware stack you built today carries forward unchanged.

Day 4 checkpoint: GET / works without a token, GET /tasks returns 401 without a token and 200 with the right one, all CRUD operations require auth, and your terminal shows [REQUEST] and [RESPONSE] lines for every call. If all of that is working, you're ready for Day 5.

← Back to Building with AI – A Developer's Diary