CMPUT-404 · Lesson 7 of 12 · 34 min

Server-side applications

Every web application has a server side that receives requests, applies rules, talks to storage and produces responses. Whether it is written in Python, PHP, JavaScript, Java, Go or Ruby, the same concerns appear: routing requests to code, rendering responses safely, remembering users between requests, protecting state-changing actions, and handling errors. This lesson builds a small Python application from the standard library alone, so that nothing is hidden by a framework, and then maps each concern onto popular frameworks and onto PHP, the language this site uses.

The request-response contract

HTTP is stateless: each request arrives with a method, a path, headers and perhaps a body, and the server must answer with a status, headers and a body. Server-side frameworks standardize the interface between web server and application code. Python's is WSGI (Web Server Gateway Interface, PEP 3333): an application is a callable that receives a dictionary of request information called environ and a start_response function, and returns the body as an iterable of bytes. Production servers such as Gunicorn and uWSGI call it; ASGI is the asynchronous successor used for WebSockets and high concurrency. Java has servlets, Node.js has its http module under Express or Fastify, and PHP has the web server hand each request to a fresh PHP script.

A minimal WSGI application invoked directly with a hand-built environ
# The smallest WSGI application, called directly the way a server would call it.
def app(environ, start_response):
    body = f"Hello from {environ['PATH_INFO']} via {environ['REQUEST_METHOD']}\n".encode()
    start_response("200 OK", [("Content-Type", "text/plain; charset=utf-8"),
                              ("Content-Length", str(len(body)))])
    return [body]

captured = {}
def start_response(status, headers):
    captured["status"], captured["headers"] = status, headers

environ = {"REQUEST_METHOD": "GET", "PATH_INFO": "/rooms", "QUERY_STRING": "building=library"}
chunks = app(environ, start_response)
print(captured["status"])
for k, v in captured["headers"]:
    print(f"{k}: {v}")
print(b"".join(chunks).decode(), end="")
Output
200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 26
Hello from /rooms via GET

Calling the application with a hand-built environ shows that there is no magic: the server parses HTTP into a dictionary, the application returns bytes, and the server writes them back. This also makes server code easy to test without a network, a technique the capstone uses.

A request passes left to right through a web server, then middleware layers for logging, sessions and CSRF checking, then a router that selects a handler; the handler calls a database and a template, and the response travels back through the middleware to the client.
The server-side request pipeline: middleware, routing, handler, data and template

Routing

A router maps a method and a URL pattern to a handler function, extracting parameters such as the room identifier. Correct routers distinguish two failures: if no pattern matches the path, the answer is 404 Not Found; if the path matches but not with this method, it is 405 Method Not Allowed with an Allow header listing the methods that are. Frameworks express routes with decorators (Flask, FastAPI), configuration files (Django's urls.py), or file names (Next.js and SvelteKit map files under a routes folder to URLs).

A decorator-based router with path parameters, 404 and 405
# A tiny router: match method and path patterns, extract parameters, return 404 or 405 correctly.
import re

ROUTES = []
def route(method, pattern):
    regex = re.compile("^" + re.sub(r"<(\w+)>", r"(?P<\1>[^/]+)", pattern) + "$")
    def register(fn):
        ROUTES.append((method, regex, fn)); return fn
    return register

@route("GET", "/rooms")
def list_rooms(): return 200, "all rooms"

@route("GET", "/rooms/<room_id>")
def show_room(room_id): return 200, f"room {room_id}"

@route("POST", "/rooms/<room_id>/bookings")
def book(room_id): return 201, f"booked {room_id}"

def dispatch(method, path):
    allowed = []
    for m, regex, fn in ROUTES:
        match = regex.match(path)
        if match:
            if m == method:
                return fn(**match.groupdict())
            allowed.append(m)
    return (405, "allow: " + ", ".join(allowed)) if allowed else (404, "not found")

for req in [("GET", "/rooms"), ("GET", "/rooms/L201"), ("POST", "/rooms/L201/bookings"),
            ("DELETE", "/rooms/L201"), ("GET", "/teachers")]:
    print(f"{req[0]:6s} {req[1]:22s} -> {dispatch(*req)}")
Output
GET    /rooms                 -> (200, 'all rooms')
GET    /rooms/L201            -> (200, 'room L201')
POST   /rooms/L201/bookings   -> (201, 'booked L201')
DELETE /rooms/L201            -> (405, 'allow: GET')
GET    /teachers              -> (404, 'not found')

Middleware wraps the whole application to handle cross-cutting concerns once: logging, compression, sessions, authentication, CSRF checks, security headers and error pages. In WSGI, middleware is just a callable that takes an application and returns another one, so layers compose like nested functions. Order matters: authentication must run before the handlers it protects, and error handling should be outermost so it catches everything.

Templates and output escaping

Server-rendered HTML combines a template with data. The single most important rule is to escape data for the context into which it is inserted: in HTML text and attributes, replace &, <, >, \" and ' with character references, so that data cannot become markup. This prevents cross-site scripting (XSS), in which attacker-controlled data runs as script in another user's browser. Template engines such as Jinja2, Django templates, Twig, Blade and JSX escape by default; the danger lies in explicitly marking data as safe or building HTML with string concatenation. Different contexts need different encoding: URLs need URL-encoding, JavaScript strings need JSON encoding (lesson 5), and CSS should never contain user data.

Escaping every substituted value neutralizes markup in user data
# Templates with automatic escaping: string.Template plus html.escape on every substituted value.
import html
from string import Template

PAGE = Template("""<h1>Bookings for $name</h1>
<p>Search: <a href="/search?q=$q">$q</a></p>
<p title="$note">$note</p>""")

def render(tpl, **values):
    return tpl.substitute({k: html.escape(str(v), quote=True) for k, v in values.items()})

# Values arrive from users and the database; none of them is trusted.
print(render(PAGE, name="Amélie O'Neil", q='"><script>steal()</script>', note="Room 2 & 3 < 5"))
Output
<h1>Bookings for Amélie O&#x27;Neil</h1>
<p>Search: <a href="/search?q=&quot;&gt;&lt;script&gt;steal()&lt;/script&gt;">&quot;&gt;&lt;script&gt;steal()&lt;/script&gt;</a></p>
<p title="Room 2 &amp; 3 &lt; 5">Room 2 &amp; 3 &lt; 5</p>

The script tag arrives in the browser as visible text, the apostrophe in the name becomes &#x27; so it cannot close a single-quoted attribute, and the ampersand and less-than in the note are displayed literally. One refinement remains: the search link is safe as HTML, but a correct URL would also URL-encode the query (urllib.parse.quote gives %22%3E%3Cscript...), because a value containing & or # would otherwise change the meaning of the URL. Escaping is always for a particular context, and nested contexts, such as a URL inside an attribute, need both encodings applied in order: URL-encode first, then HTML-escape.

Sessions and cookies

Because HTTP is stateless, applications remember users with cookies: the server sends Set-Cookie, and the browser returns the value in a Cookie header on later requests to the same site. A session either stores data on the server keyed by a random identifier in the cookie (PHP's default, and Django's database sessions), or stores the data in the cookie itself, signed with a server secret so it cannot be altered (Flask's default). Signed cookies are readable by the user, so they must not contain secrets, and their size is limited to about 4 KB.

Session handling has its own rules. Generate session identifiers with a cryptographically secure random generator. Issue a new session identifier when a user signs in, so that an identifier an attacker planted earlier (session fixation) becomes useless. Expire idle sessions, and destroy the server-side session on sign-out. Store passwords only as slow, salted hashes such as Argon2id or bcrypt, a topic CMPUT-331 covers.

Cross-site request forgery

Cookies are sent automatically, which creates a risk: a malicious page can make the user's browser submit a form to your site, and the browser attaches the session cookie. If the site trusts the cookie alone, it performs the action as the user. This is cross-site request forgery (CSRF). Defences combine several layers: SameSite=Lax cookies stop most cross-site POSTs; a synchronizer token, a random value stored in the session and embedded in each form, must be returned with every state-changing request, which another site cannot read; and checking the Origin or Sec-Fetch-Site header rejects requests from foreign origins. Also, never change state on GET requests, since links and image tags can trigger GETs freely.

Putting it together

The application below combines all of these pieces in about seventy lines. It signs its session cookie with HMAC-SHA256, creates a new session at login, embeds a CSRF token in its booking form, requires both a signed-in session and a matching token before accepting a booking, escapes output, and sets HttpOnly, Secure and SameSite on its cookie. The test client is an ordinary urllib opener with a cookie jar, playing the role of a browser.

A complete standard-library application with signed sessions, CSRF tokens and escaping, exercised by a test client
# A small but complete server-side application: routing, templates, signed sessions, cookies and CSRF.
import hashlib, hmac, html, http.cookies, json, secrets, base64, threading
import urllib.parse, urllib.request, urllib.error, http.cookiejar
from wsgiref.simple_server import make_server, WSGIRequestHandler

SECRET = secrets.token_bytes(32)          # in production: from the environment, never in code
BOOKINGS = []

def sign(data):                            # session = base64(json) + "." + HMAC
    raw = base64.urlsafe_b64encode(json.dumps(data).encode()).decode()
    return raw + "." + hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest()

def unsign(value):
    raw, _, mac = (value or "").rpartition(".")
    good = hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest()
    return json.loads(base64.urlsafe_b64decode(raw)) if raw and hmac.compare_digest(mac, good) else {}

def app(environ, start_response):
    method, path = environ["REQUEST_METHOD"], environ["PATH_INFO"]
    jar = http.cookies.SimpleCookie(environ.get("HTTP_COOKIE", ""))
    session = unsign(jar["sid"].value) if "sid" in jar else {}
    session.setdefault("csrf", secrets.token_urlsafe(16))
    status, body = "200 OK", ""
    if method == "POST" and path == "/login":
        form = urllib.parse.parse_qs(environ["wsgi.input"].read(int(environ["CONTENT_LENGTH"])).decode())
        session = {"user": form["user"][0], "csrf": secrets.token_urlsafe(16)}   # new session on login
        status, body = "303 See Other", ""
    elif method == "GET" and path == "/book":
        body = (f'<form method="post" action="/book"><input type="hidden" name="csrf" '
                f'value="{session["csrf"]}"><input name="room"></form>')
    elif method == "POST" and path == "/book":
        form = urllib.parse.parse_qs(environ["wsgi.input"].read(int(environ["CONTENT_LENGTH"])).decode())
        if "user" not in session:
            status, body = "401 Unauthorized", "sign in first"
        elif not hmac.compare_digest(form.get("csrf", [""])[0], session["csrf"]):
            status, body = "403 Forbidden", "bad CSRF token"
        else:
            BOOKINGS.append((session["user"], form["room"][0]))
            status, body = "201 Created", f"booked {html.escape(form['room'][0])} for {html.escape(session['user'])}"
    else:
        status, body = "404 Not Found", "no such page"
    headers = [("Content-Type", "text/html; charset=utf-8"),
               ("Set-Cookie", f"sid={sign(session)}; Path=/; HttpOnly; Secure; SameSite=Lax")]
    if status.startswith("303"): headers.append(("Location", "/book"))
    start_response(status, headers)
    return [body.encode()]

class Quiet(WSGIRequestHandler):
    def log_message(self, *a): pass
srv = make_server("127.0.0.1", 0, app, handler_class=Quiet)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}"

class Jar(http.cookiejar.CookieJar):        # test client: keep cookies even though we use plain http locally
    def add_cookie_header(self, request):
        for c in self: request.add_unredirected_header("Cookie", f"{c.name}={c.value}")
    def extract_cookies(self, response, request):
        for v in response.headers.get_all("Set-Cookie") or []:
            name, _, rest = v.partition("=")
            self.set_cookie(http.cookiejar.Cookie(0, name, rest.split(";")[0], None, False, "127.0.0.1",
                            False, False, "/", True, False, None, False, None, None, {}))
jar = Jar()
browser = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))

def post(path, data, client=browser):
    try:
        r = client.open(base + path, urllib.parse.urlencode(data).encode()); return r.status, r.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode()

print("POST /book before login:", post("/book", {"room": "L201", "csrf": "x"}))
r = browser.open(base + "/login", b"user=amelie")          # 303 redirect is followed to GET /book
form = r.read().decode()
token = form.split('name="csrf" value="')[1].split('"')[0]
print("after login, form status:", r.status, "| CSRF token length:", len(token))
print("POST /book with token:   ", post("/book", {"room": "L201", "csrf": token}))
print("POST /book forged token: ", post("/book", {"room": "S105", "csrf": "guess"}))
cookie = next(iter(jar)).value
tampered = cookie.replace(cookie[5], "A" if cookie[5] != "A" else "B", 1)
print("tampered session valid?  ", bool(unsign(tampered)), "| genuine valid?", bool(unsign(cookie)))
print("bookings stored:", BOOKINGS)
srv.shutdown()
Output
POST /book before login: (401, 'sign in first')
after login, form status: 200 | CSRF token length: 22
POST /book with token:    (201, 'booked L201 for amelie')
POST /book forged token:  (403, 'bad CSRF token')
tampered session valid?   False | genuine valid? True
bookings stored: [('amelie', 'L201')]

Each line of output demonstrates a defence. Without signing in, the booking is refused with 401. After login, the server redirects to the form with 303 See Other, the Post/Redirect/Get pattern that prevents a browser refresh from resubmitting the login form. A booking with the correct token succeeds with 201, while a forged token is refused with 403. Changing a single character of the session cookie invalidates its signature, so users cannot promote themselves by editing the cookie. Note the use of hmac.compare_digest, which takes the same time whether strings differ early or late, avoiding timing leaks.

Frameworks and PHP

Real projects use frameworks that package these pieces with years of security fixes. Django is a full-stack Python framework with an ORM, admin interface, migrations, forms and built-in CSRF protection; Flask and FastAPI are smaller, with FastAPI generating OpenAPI documentation from type hints (lesson 9). Express and Fastify serve Node.js; Spring Boot serves Java; Ruby on Rails popularized convention over configuration; Laravel and Symfony dominate PHP. PHP itself, which powers a large share of websites, including WordPress and this site, starts a fresh process for each request, which makes it simple to deploy and naturally stateless; sessions live in files or a database.

The same concerns in plain PHP: session cookie flags, CSRF token, parameterized query, Post/Redirect/Get and escaping (listing)
<?php
// How a PHP page handles the same concerns (listing; the pattern used by sites such as this one).
session_set_cookie_params(['httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
session_start();                                           // server-side session, id in a cookie
$_SESSION['csrf'] ??= bin2hex(random_bytes(16));

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) { http_response_code(403); exit('Bad token'); }
    $stmt = $pdo->prepare('INSERT INTO bookings (user_id, room) VALUES (?, ?)');   // parameterized
    $stmt->execute([$_SESSION['user_id'], $_POST['room']]);
    header('Location: /bookings', true, 303);                // Post/Redirect/Get
    exit;
}
$h = fn($s) => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');  // escape on output
?>
<form method="post">
  <input type="hidden" name="csrf" value="<?= $h($_SESSION['csrf']) ?>">
  <label>Room <input name="room" required></label> <button>Book</button>
</form>

Errors, logging and configuration

Production applications must fail gracefully. Catch unexpected exceptions in outer middleware, log the details with a request identifier, and show the user a generic error page with that identifier rather than a stack trace, since tracebacks reveal file paths, library versions and sometimes data. Turn off debug modes in production; PHP's display_errors must be off on public servers. Configuration such as database credentials and session secrets belongs in environment variables or a secrets manager, not in source code or version control, a principle lesson 11 develops as part of the twelve-factor approach.

Worked examples

Worked problem

Choosing the right status code

For the router in this lesson, what should the responses be for: GET /rooms/L201; PUT /rooms/L201 (not routed); GET /buildings; a POST /book with a bad token from a signed-in user?

Show solution
  1. GET /rooms/L201 matches show_room: 200.
  2. PUT /rooms/L201: the path matches GET routes only, so 405 with Allow: GET.
  3. GET /buildings: no route matches the path, so 404.
  4. A signed-in user with a bad CSRF token is authenticated but not permitted: 403.

Answer: 200, 405 (Allow: GET), 404 and 403 respectively.

Worked problem

Tracing a CSRF attempt

While signed in to the booking site, a user visits evil.example, which auto-submits a hidden form POSTing room=ALL to the booking site. Which defences stop it?

Show solution
  1. With SameSite=Lax, the browser does not attach the session cookie to a cross-site POST, so the request arrives unauthenticated.
  2. Even if the cookie were sent (SameSite=None or an old browser), evil.example cannot read the CSRF token from the booking page, so the token check fails with 403.
  3. An Origin header of https://evil.example would also fail an origin check.

Answer: SameSite cookies, the synchronizer token and origin checking each block it independently: defence in depth.

Worked problem

Reviewing a template

A template writes <a href="/u/{{ name }}">{{ name|safe }}</a>. What is wrong?

Show solution
  1. The |safe filter disables escaping, so a name containing <script> becomes executable markup.
  2. In the href, the name should be URL-encoded as a URL path segment, not just HTML-escaped.
  3. Fix: remove |safe and build the URL with a URL-encoding helper such as urlencode or url_for.

Answer: Restore escaping for the text and URL-encode the path segment.

Common mistakes

  • MistakeEscaping input when it arrives is enough.

    InsteadEscape on output for the specific context (HTML, attribute, URL, JavaScript); store data in its original form.

  • MistakeA signed cookie keeps its contents secret.

    InsteadSigning prevents tampering, not reading; store secrets on the server or encrypt.

  • MistakeHttpOnly prevents XSS.

    InsteadIt only stops scripts reading the cookie; injected scripts can still act as the user. Escaping prevents XSS.

  • MistakeGET requests can delete or change things if they are links in the admin page.

    InsteadState changes must use POST, PUT, PATCH or DELETE with CSRF protection.

  • MistakeKeep the same session id after login.

    InsteadIssue a new id at login to defeat session fixation.

  • MistakeShow stack traces to help users report bugs.

    InsteadLog details server-side and show a generic page with a request id.

Summary and key terms

  • Server interfaces such as WSGI turn HTTP requests into function calls returning status, headers and body.
  • Routers map method and path to handlers and distinguish 404 from 405.
  • Middleware handles cross-cutting concerns like sessions, CSRF and errors.
  • Escape all data on output for its context to prevent XSS.
  • Sessions use cookies; set HttpOnly, Secure and SameSite, and renew the id at login.
  • Defend state-changing requests against CSRF with SameSite, tokens and origin checks.
  • Hide errors from users, log them, and keep secrets in configuration, not code.
WSGI
The Python standard interface between web servers and applications.
Router
Code that maps request methods and paths to handlers.
Middleware
A layer wrapping an application to process every request and response.
XSS
Cross-site scripting: attacker-controlled data executing as script in a page.
Session
Server-recognized state linking a series of requests to one user.
HttpOnly
A cookie attribute that hides the cookie from JavaScript.
SameSite
A cookie attribute controlling whether it is sent with cross-site requests.
CSRF
Cross-site request forgery: tricking a browser into sending an authenticated request.
Synchronizer token
A per-session secret embedded in forms to prove a request came from the site.
Post/Redirect/Get
Responding to a form POST with a redirect so refreshing does not resubmit.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What does a WSGI application receive and return?

    Show answer

    It receives environ and start_response, and returns an iterable of bytes.

  2. When should a router return 405 instead of 404?

    Show answer

    When the path matches a route but the method is not allowed for it.

  3. Which characters must be escaped in HTML text and attributes?

    Show answer

    &, <, >, double quote and single quote.

  4. Why should a new session id be issued at login?

    Show answer

    To prevent session fixation, where an attacker supplies an id before login.

  5. What does SameSite=Lax do?

    Show answer

    Omits the cookie on cross-site subrequests and cross-site POSTs, but sends it on top-level navigation.

  6. In the application run, why was the forged booking rejected with 403 rather than 401?

    Show answer

    The user was authenticated, but the CSRF token did not match, so the action was forbidden.

  7. Why does the application use hmac.compare_digest?

    Show answer

    For constant-time comparison that does not leak information through timing.

  8. Why does login respond with 303 See Other?

    Show answer

    Post/Redirect/Get: the browser then GETs the next page, so a refresh does not resubmit the form.

  9. Can users read the contents of a signed session cookie?

    Show answer

    Yes; signing prevents modification, not disclosure.

  10. Where should a session signing secret be stored?

    Show answer

    In an environment variable or secrets manager, not in source code.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz