Step 131. Build Your Own Web Server — Login and Sessions
Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★☆☆ | Estimated time: 3 hours
Prerequisites: Step 94 (a taste of Flask), Step 92 (sqlite3), and Step 73 (HTTP).
- What you need: Python with Flask, and
curl. This chapter’s measurements were performed on Flask 3.1.3, Python 3.12, at127.0.0.1(localhost). - Caution: the server you build today will keep being used as the target for all of this book’s web-attack practice. Do not publish the code anywhere external like GitHub; run it only inside your own computer.
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
HTTP has no state — the server treats every request like a stranger it’s meeting for the first time. So why does a login stay logged in? Because at the moment of login, the server hands you a session ID as a cookie, and checks it on every subsequent request. To attack something, you first have to know how to build it. Today you will write a working web app with login, sessions, and logout yourself, and watch from the inside the entire process of a session cookie being created and verified.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Create accounts in a sqlite3 DB and implement login verification
- Maintain login state with Flask’s
sessionandsecret_key - Attach a "login check" to pages that need protection
- Decode and explain the structure of a session cookie (base64 contents + signature)
- Demonstrate that cookie tampering is rejected thanks to the signature
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + Flask 3.x + sqlite3, localhost development server, curl |
| Today’s code | session["user"] = uid, app.secret_key, redirect, "user" not in session, session.clear() |
| Concepts needed | Stateless HTTP, cookies, sessions, signatures, 302 redirects |
| Today’s artifact | One DB-backed login web app + a session-cookie observation record |
2-1. Stateless HTTP and Cookies — Solving "And You Are… Again?"
As you learned in Step 73, every HTTP request is independent. Even if you log in successfully, the server doesn’t know you on the next request. The device that compensates for this stateless nature is the cookie.
The flow goes like this: ① login succeeds → ② the server stamps a ticket on the response with a Set-Cookie header → ③ the browser automatically attaches that ticket in a Cookie header on every later request → ④ the server sees the ticket and knows "ah, that person from before." What you build today is exactly these four beats.
2-2. Sessions — Login State the Server Remembers
A session is the server’s way of managing "the logged-in state." Flask’s session uses a peculiar implementation — instead of the server’s disk, the session contents go inside the cookie, entrusted to the browser.
Then what if the browser swaps the contents? That’s what the signature is for. The server stamps the cookie’s contents with its secret_key and inspects the stamp on returning cookies. The contents are visible, but if you alter them the stamp no longer matches and the cookie is discarded. This fact — "a session cookie can be read but not forged" — is the starting point of every session attack.
2-3. The 302 Redirect — "Go Away, Log In First"
Open a protected page without logging in, and instead of content the server returns 302 Found + a Location: /login header. It’s an instruction meaning "don’t come here, go there," and the browser moves to that address automatically.
Today let’s distinguish three status codes — 200 (normal response), 302 (instruction to move to another address), 401 (authentication failure). As you learn web attacks, these numbers start sounding like the server’s tone of voice.
3. Follow Along
3-1. Creating the Account DB
Using sqlite3 from Step 92, create the account table. In the same folder, init_db.py:
import sqlite3
conn = sqlite3.connect("users.db")
conn.execute("CREATE TABLE IF NOT EXISTS users (username TEXT, password TEXT)")
conn.execute("DELETE FROM users")
conn.executemany("INSERT INTO users VALUES (?, ?)",
[("nadia", "blue-fox-31"), ("guest", "guest")])
conn.commit()
conn.close()
print("users.db ready")
Output (measured 2026-09-09, python init_db.py):
users.db ready
How to read it: the question-mark binding (?) is the SQL injection defense you learned in Step 93. For now we store plaintext passwords — a real service would have to store hashes. This weakness becomes attack material later, so for now just remember that this is "deliberately simple."
3-2. The Complete Login + Session Server Code
Create app.py. All of today’s practice happens in this one file.
from flask import Flask, request, redirect, session
import sqlite3
app = Flask(__name__)
app.secret_key = "lab-secret-key-1234" # for session signing — a lab value. Use a long random one in production
def check_user(uid, pw):
conn = sqlite3.connect("users.db")
cur = conn.execute("SELECT * FROM users WHERE username=? AND password=?", (uid, pw))
row = cur.fetchone()
conn.close()
return row is not None
LOGIN_FORM = '''
<h1>Login</h1>
<form method="post" action="/login">
<input name="uid" placeholder="Username">
<input name="pw" type="password" placeholder="Password">
<button>Log in</button>
</form>
'''
@app.route("/")
def home():
return '<h1>Step 131 Lab</h1><a href="/login">Login</a> | <a href="/dashboard">Dashboard</a>'
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
uid = request.form.get("uid", "")
pw = request.form.get("pw", "")
if check_user(uid, pw):
session["user"] = uid
return redirect("/dashboard")
return "Login failed: wrong username or password.", 401
return LOGIN_FORM
@app.route("/dashboard")
def dashboard():
if "user" not in session:
return redirect("/login")
return f"<h1>{session['user']}'s Dashboard</h1>Secret file list... <a href='/logout'>Log out</a>"
@app.route("/logout")
def logout():
session.clear()
return 'Logged out. <a href="/">Home</a>'
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
The skeleton is the same as Step 94, and only four things are new — session (the login-state pocket), secret_key (the signing stamp), redirect (sending a 302), and the DB check function. Run it and leave it running — output (measured 2026-09-09):
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
3-3. Going to the Dashboard Without Logging In — Stopped at the Threshold?
With the server running, open a new terminal and check with curl (a browser works too — type /dashboard in the address bar and you’ll be bounced to login).
Input
curl -i http://127.0.0.1:5000/dashboard
Output (measured 2026-09-09):
HTTP/1.1 302 FOUND
Server: Werkzeug/3.1.8 Python/3.12.14
Content-Type: text/html; charset=utf-8
Content-Length: 199
Location: /login
Vary: Cookie
How to read it: you didn’t log in, so 302 and Location: /login — a redirect saying "log in first, then come back." The single line "user" not in session played the role of gatekeeper.
3-4. Wrong Login and Right Login — The Server’s Two Faces
Input ① wrong password
curl -i -X POST -d "uid=nadia&pw=wrongpass" http://127.0.0.1:5000/login
Output (measured 2026-09-09): HTTP/1.1 401 UNAUTHORIZED — the body is "Login failed: wrong username or password." (41 bytes).
Input ② correct account — save the cookie to a file
curl -i -c jar.txt -X POST -d "uid=nadia&pw=blue-fox-31" http://127.0.0.1:5000/login
Output (measured 2026-09-09):
HTTP/1.1 302 FOUND
Location: /dashboard
Set-Cookie: session=eyJ1c2VyIjoibmFkaWEifQ.aqEDqQ.P-l1eqkS217ha8ndH5jXuIcezv8; HttpOnly; Path=/
How to read it: today’s protagonist is the Set-Cookie line. In exchange for a successful login, the server issued a session cookie. Split the value on dots (.) and you get three pieces — front (contents, base64) / middle (issue time) / back (signature). -c jar.txt saved the received cookie to a file; -b jar.txt loads a cookie into the request.
3-5. Entering the Dashboard with the Cookie — Show the Ticket and It Opens
Input
curl -i -b jar.txt http://127.0.0.1:5000/dashboard
Output (measured 2026-09-09):
HTTP/1.1 200 OK
Vary: Cookie
<h1>nadia's Dashboard</h1>Secret file list... <a href='/logout'>Log out</a>
How to read it: the address that chased you away with a 302 in 3-3 now returns 200 and a personalized body ("nadia’s") once you attach one cookie. The cookie is the ID card — this one sentence is the entire motive of cookie-theft attacks.
3-6. Dissecting the Session Cookie — The Contents Are Visible
Let’s decode the first piece of the cookie you received in 3-4.
Input
echo "eyJ1c2VyIjoibmFkaWEifQ" | python -c "import sys,base64; s=sys.stdin.read().strip(); s+='='*(-len(s)%4); print(base64.urlsafe_b64decode(s).decode())"
Output (measured 2026-09-09):
{"user":"nadia"}
How to read it: a shocking fact — the session contents sit right inside the cookie. base64 is not encryption but letter-shuffling, so anyone can reverse it. Flask sessions are not "remembered by the server" but "carried around by the browser."
3-7. Then What About Forgery? — The Signature Blocks It
If the contents are visible, can you alter them? Let’s make a fake cookie changing nadia to admin — leaving the trailing signature piece as is.
Input
FORGED=$(python -c "import base64; print(base64.urlsafe_b64encode(b'{\"user\":\"admin\"}').decode().rstrip('='))")
curl -i -H "Cookie: session=$FORGED.aqEDqQ.P-l1eqkS217ha8ndH5jXuIcezv8" http://127.0.0.1:5000/dashboard
Output (measured 2026-09-09):
HTTP/1.1 302 FOUND
Location: /login
How to read it: treated as someone not logged in — chased away to the login page. You altered the contents, but the signature (the trailing piece) was computed against the old contents, so it failed the stamp inspection. To make a new signature you need the secret_key, and that key lives only inside the server. "Visible" and "changeable" are different — the signature guards the space between them.
3-8. Logout and the Server Log — Reading the Traces
Input
curl -i -b jar.txt -c jar.txt http://127.0.0.1:5000/logout
Output (measured 2026-09-09):
HTTP/1.1 200 OK
Set-Cookie: session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly; Path=/
How to read it: logout is implemented as "overwrite the cookie with an empty value + a past expiration date." With this cookie, /dashboard now bounces you back with a 302 (measured 2026-09-09).
Finally, the log left on the server console during all these requests (measured 2026-09-09):
127.0.0.1 - - [09/Sep/2026 15:59:22] "POST /login HTTP/1.1" 302 -
127.0.0.1 - - [09/Sep/2026 15:59:22] "GET /dashboard HTTP/1.1" 200 -
127.0.0.1 - - [09/Sep/2026 15:59:22] "GET /dashboard HTTP/1.1" 302 -
Read in time order, a story appears — login success (302), dashboard entry (200), and the forged cookie’s expulsion (302). Every request you make in the web-attack chapters gets recorded in this log just like this. The sense that an attacker’s actions pile up as records on the server — be sure to take it with you.
4. Missions & Exercises
Mission — Completing My Login Web App
- Type out today’s
app.pyyourself and createusers.dbwith 2 accounts. - Capture each of the three scenes as curl output: correct login → dashboard entry, wrong login → 401, logout → dashboard blocked.
- Leave output showing the issued session cookie base64-decoded and its contents confirmed.
- Leave output of a forgery attempt — with one cookie character changed — being rejected with a 302.
- Write
session-and-cookies.mdin your wiki — 3 lines each on ① the stateless-HTTP problem ② the cookie issuance-to-verification flow ③ what the signature blocks and what it can’t. - (Challenge) Add a
/whoamiroute that returns "your username if logged in, otherwise ‘guest.’"
Exercises
Exercise 1. Explain what it means that HTTP is stateless, and how cookies solve this problem.
Exercise 2. Anyone can decode the contents of a Flask session cookie, yet it’s safe — why? Answer including the scope of "safe" (what it blocks and what it can’t).
Exercise 3. When a protected page is opened without a login, what is the advantage of the design that returns 302 + Location: /login instead of 401?
Exercise 4. Read the three server-log lines in 3-8 and explain which user action each line corresponds to.
5. Model Answers & Completion Criteria
Mission Model Answer
Key verification points:
- Login success path (must have the same shape as the 2026-09-09 measurement):
POST /login→ 302 +Set-Cookie: session=...; HttpOnly; Path=/→GET /dashboardwith the cookie attached → 200. - Failure path: a wrong password gets 401 and a failure message. Here you must NOT distinguish "wrong username" from "wrong password" — that would tell an attacker whether an account exists. Blur them into one message like the model answer’s "wrong username or password."
- Forgery rejection: a tampered cookie is quietly redirected to the login page — not raising an error is Flask’s normal behavior.
- Challenge answer:
@app.route("/whoami")
def whoami():
return f"Current user: {session.get('user', 'guest')}"
Exercise Answers
Answer 1. Stateless means the server processes each request independently and cannot remember previous requests (a successful login). Cookies mimic state by having the browser automatically attach (Cookie) the ticket the server issued at login (Set-Cookie) to every later request — "every request comes with an ID card presented."
Answer 2. Because the contents are visible but forgery is impossible. The server signs the cookie contents with its secret_key and verifies the signature on returning cookies, discarding them if the contents changed (3-7 measurement: the cookie altered to admin was expelled to the login page). Note, however, that this scheme blocks tampering only — theft, where the whole cookie is stolen and used as is, passes signature verification and cannot be blocked. Those defenses (expiration, HttpOnly, etc.) are the subject of the later cookie-attack chapter.
Answer 3. A 302 redirect guides the user to the login screen — the browser moves automatically, so the user flows naturally to a "page that requires login." 401 is a code of "rejection" and tells the user nothing about the next action. In practice, unauthenticated access to a protected page usually gets a 302 (to login), while 401 is used when "authentication itself failed," as in APIs — which is why our wrong login is a 401.
Answer 4. The first line (POST /login 302) is a login success — a 302 sending them to the dashboard. The second line (GET /dashboard 200) is normal entry to the dashboard with the issued cookie. The third line (GET /dashboard 302) is a request that approached without a cookie (or with a forged one) and was chased to login. Same address, but the status code is the verdict.
Completion Criteria Checklist
- [ ] I can create a sqlite3 account DB and connect it to login verification
- [ ] I can record and check login state with
session["user"] - [ ] I can explain the roles of
secret_keyand the signature - [ ] I can implement a protected page’s 302 redirect and confirm it with curl
- [ ] I base64-decoded a session cookie and confirmed its contents
- [ ] I demonstrated that a tampered cookie is rejected
- [ ] Mission: I completed the three scenes’ outputs and
session-and-cookies.md
6. Common Pitfalls & Fixes
Wall 1. RuntimeError as soon as I use session
Symptom (actual error form):
RuntimeError: The session is unavailable because no secret key was set.
Cause: you didn’t set app.secret_key. Sessions need signatures, and signatures need a key.
Fix: add one line, app.secret_key = "any string", before defining routes. Any value works for practice, but a real service manages a long random value as an environment variable.
Wall 2. The dashboard keeps bouncing me to login even after a successful login
Symptom: POST /login returns 302, but /dashboard sends me back to /login.
Cause: with curl, you saved the cookie (-c) but didn’t attach it to the request (-b), or vice versa. With a browser, suspect a cookie-blocking setting.
Fix: for curl, -c jar.txt (receive) and -b jar.txt (send) come as a set — look at 3-4 and 3-5 again.
Wall 3. I changed the code but the cookie value stays the same
Symptom: I modified the server code, but the issued cookie doesn’t change.
Cause: the browser/curl keeps carrying the old cookie. The server didn’t issue a new one — the client is presenting the old one.
Fix: delete jar.txt and log in again from the start. In a browser, delete it in Developer Tools → Application → Cookies.
Wall 4. I get sqlite3.OperationalError: no such table: users
Symptom (the actual error message verbatim):
sqlite3.OperationalError: no such table: users
Cause: you didn’t run init_db.py, or you launched the server from a different folder so a new empty users.db was created. sqlite3 quietly creates a new file if none exists.
Fix: run python init_db.py in the same folder as app.py, then launch the server. Check that you don’t have two users.db files — a classic of sqlite practice.
Wall 5. I get 405 Method Not Allowed when submitting the login form
Symptom (same measurement as Step 94): 405 METHOD NOT ALLOWED on form submission.
Cause: you omitted methods=["GET", "POST"] in the route, or method="post" in the form tag.
Fix: check both the decorator and the form tag — they’re a set (review Step 94, Wall 3).
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Stateless | HTTP requests cannot remember each other |
| Cookie | A ticket the server stamps and the browser carries around |
| Session | Login state — Flask’s way puts the contents in the cookie |
| Signature | A stamp made with secret_key — detects tampering |
| 302 redirect | "Go to that address" — the gatekeeper of protected pages |
| HttpOnly | An attribute that stops JS from reading the cookie (auto-attached to today’s issued cookie) |
Today’s Code & Commands
| Code/command | What it does |
|---|---|
app.secret_key = "..." |
Set the secret key for session signing |
session["user"] = uid |
Record login state (leads to cookie issuance) |
"user" not in session |
Login check (the gatekeeper) |
session.clear() |
Logout (empty the session) |
redirect("/login") |
302 + Location response |
curl -c jar.txt / curl -b jar.txt |
Save cookie / attach cookie |
base64.urlsafe_b64decode(...) |
Decode session-cookie contents |
An Instinct More Important Than Commands
Today you saw the back side of the everyday fact that "logins stay logged in" — on every request a cookie ID card goes back and forth, and the server inspects the stamp. With this one sense that the cookie is the ID card, the attacks of the coming chapters — stealing cookies (XSS), tampering with them (breaking signatures), planting them (session fixation) — all start reading as "why does that work?"
And don’t forget — the server you built today is from now on this book’s target. The more attacks you learn, the more innocent this code will look, and you’ll learn how to fix it then. Only someone who has built it knows exactly the moment it falls.
Once every box is checked, Step 131 is complete. Click the checkbox in the sidebar to save your progress.