What would you like to learn?

Try PowerShell, networks, XSS, or Step 138

Browse the full curriculum →

Developer tools

Step 94. A Taste of Flask — Building Your Own Web Server

Step 94Estimated practice · 3 hours

Level 1 — Programming and the Inside of a Computer | Difficulty ★★★☆☆ | Estimated time: 3 hours

Prerequisites: HTTP knowledge from Step 73 and Python basics from Steps 41–46.

  • What you need: Python and pip install flask. This chapter’s measurements were performed on Flask 3.1.3 at 127.0.0.1 (localhost).
  • Caution: from today, you build the side that receives requests (the server), not the side that sends them (the client). All exercises run on a development server inside your own computer and are never exposed externally. In the second half there’s an experiment where you build a deliberately vulnerable page — only inside your server and your browser.

In Step 73 we learned how to send HTTP requests. But one thing was missing — what happens on the side that receives the request? Today we open that black box. The tool is Flask — the smallest web framework, able to launch a web server with a few lines of Python. A vulnerability isn’t an abstract concept; it’s a hole in code someone wrote. So you’ll write the vulnerable code yourself — in today’s final scene, you’ll feel XSS (cross-site scripting) at your fingertips for the first time.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Launch a server with two or more routes using Flask
  • Receive and handle query strings (GET) and form data (POST) respectively
  • Build a JSON API by returning a dictionary
  • Trace a request by comparing the browser’s Network tab with the server log
  • Explain code where XSS occurs, its principle (code-data mixing), and escaping as a defense

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 + Flask 3.x, a localhost (127.0.0.1) development server
Today’s code @app.route (routing), request.args (GET parameters), request.form (POST forms), dictionary return (JSON), markupsafe.escape (escaping)
Concepts needed Routing, GET/POST, query strings, status codes (200/404/405), XSS and escaping
Today’s artifacts A mini guestbook web app + XSS success/block contrast captures

2-1. Frameworks and Routing — Connecting URLs to Functions

The essence of what a web server does is simple: "when this request comes to this address, run this function and return its result." Connecting an address (URL) to a function is called routing. In Flask, one decorator (@) line is a routing.

@app.route("/hello")
def hello():
    return "Hi!"

"When a GET request comes to /hello, run hello() and send its return value as the response body." These two lines are both the whole and the beginning of a web server.

2-2. GET and POST — Seen Again from the Server’s Side

Let’s organize the two methods from Step 73, this time from the receiving side’s viewpoint.

  • GET — a request attached to the address bar. The part after ? in /hello?name=hacker (the query string) is the parameters. The server pulls them out with request.args.
  • POST — a request where data rides in the body. Values "that must not show in the address bar," like a login form, come this way. The server pulls them out with request.form.

2-3. The Server-Side Viewpoint — "All Input Is Suspicious"

Once you start writing server code, your worldview changes. Until now you were the browser’s user, but from today you’re the side asking "is whatever sent that request really a nice user?"

Every input the server receives — URL parameters, form text boxes, cookies — can be manipulated at will by the user, because anyone can change values by opening developer tools. "Every input arriving at the server may be contaminated" — this one sentence is web security’s first principle, and today’s XSS experiment is its proof.


3. Follow Along

3-1. Installation and the Minimal Server

Input

pip install flask
mkdir flask-lab && cd flask-lab

app.py:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return "Hello, Web!"

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000)

Output (measured 2026-09-09, python app.py):

 * 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

How to read it: the server is up on port 5000. Open http://127.0.0.1:5000 in a browser and you’ll see Hello, Web! (measured 2026-09-09 — this body came back with HTTP status 200). The warning ("don’t use the development server in production") is Flask’s normal guidance. To make the server restart itself whenever you edit code, use app.run(..., debug=True) — but keep debug mode on only during development, since error screens expose code.

Why: a server ran in five lines. The framework handles all the tedious parts (sockets, HTTP parsing) for you, so you only have to think about "what to show."

3-2. A Request’s Journey — Watching the Browser and Console Simultaneously

Input: in the browser, open developer tools (F12) → the Network tab, and refresh http://127.0.0.1:5000.

Output (measured 2026-09-09, server console):

127.0.0.1 - - [09/Sep/2026 14:29:13] "GET / HTTP/1.1" 200 -

How to read it: two faces of the same event. The browser’s Network tab shows one request line (status 200); the server console shows one access-log line — who (127.0.0.1), when, what (GET /), and how it turned out (200) are stamped.

Why: engraving the correspondence "one request = one log line" in your body is the foundation for the instinct of tracing attacks through server logs later.

3-3. Receiving URL Parameters — Query Strings

Input: add a route to app.py.

from flask import request

@app.route("/hello")
def hello():
    name = request.args.get("name", "guest")
    return f"Hello, {name}!"

Output (measured 2026-09-09): http://127.0.0.1:5000/hello?name=hackerHello, hacker!. Opening just /hello without parameters → Hello, guest!.

How to read it: the server pulled out the value of ?name=hacker and loaded it into the response. The second argument of request.args.get("name", "guest") is the default when the parameter is absent.

Why: your first experience of a server receiving and processing user input. And as you may have noticed — that name can be changed at will by the user. This fact explodes in 3-7.

3-4. Forms and POST — A Login Impression

Input: add a login page and a handling route.

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        uid = request.form.get("uid")
        pw = request.form.get("pw")
        return f"login attempt with ID {uid} (password length: {len(pw)})"
    return '''
        <form method="post">
          <input name="uid" placeholder="ID">
          <input name="pw" type="password" placeholder="password">
          <button>Log in</button>
        </form>
    '''

Output (measured 2026-09-09): open /login (GET) and the form shows; enter ID tester and an 8-character password and submit (POST), and this comes back.

login attempt with ID tester (password length: 8)

How to read it: the same /login address, but different behavior by method — GET shows the form, POST handles it. It must say <form method="post"> for data to go in the body (POST). Note that we showed only the length of the password instead of echoing it back on screen — showing a received secret back is a bad habit from the start.

Why: every web service’s login has this structure. Only by understanding the separation of the GET/POST requests can you later answer forensic questions like "how many times did login attempts get stamped in the log?"

3-5. Make a Prediction — What Gets Stamped on the Server Console?

Prediction: open the /login form (first visit) and press the submit button — what logs get stamped on the server console?

  • (a) One line of "GET /login ..."
  • (b) One line of "POST /login ..."
  • (c) One line each of GET and POST

Check for yourself — output (measured 2026-09-09):

127.0.0.1 - - [09/Sep/2026 14:29:13] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [09/Sep/2026 14:29:13] "POST /login HTTP/1.1" 200 -

The answer is (c). "Viewing the form" and "sending the form" are two separate requests.

Why it matters: server logs don’t lie. The first step of attack tracing is reading these lines one by one in time order.

3-6. JSON Responses — A Server That Returns Data, Not Screens

So far we returned strings for humans to read. A server that talks to other programs (an API) instead returns JSON (structured data).

Input

@app.route("/api/status")
def api_status():
    return {"service": "netwatch-lab", "open_ports": [8000], "status": "ok"}

Output (measured 2026-09-09, visiting http://127.0.0.1:5000/api/status):

{"open_ports":[8000],"service":"netwatch-lab","status":"ok"}

How to read it: return a Python dictionary and Flask automatically translates it to JSON. It looks like a blob of characters in the browser, but this response is in a form another program can read mechanically.

Why: the tool you’ll build in the comprehensive project (Step 95) doubles as exactly this kind of "server that returns data." Distinguishing responses-for-screens from responses-for-data is today’s quiet harvest.

3-7. A Taste of Vulnerability — The Birth of XSS

Now we write bad code on purpose. A route that loads input straight into HTML.

Input

@app.route("/echo")
def echo():
    name = request.args.get("name", "")
    return f"<h1>your input: {name}</h1>"   # inserting input into HTML as-is — vulnerable!

First a normal input — /echo?name=gildong shows your input: gildong as a bold heading. Now the attack input. In the address bar:

http://127.0.0.1:5000/echo?name=<script>alert('XSS')</script>

Output (measured 2026-09-09, server response body):

<h1>your input: <script>alert('XSS')</script></h1>

The server loaded the input as-is with no processing and returned it, and the browser receiving this response interpreted <script> not as "content" but as a "command" and executed the alert box. The input was executed as a script, not data.

How to read it: this is XSS (cross-site scripting) — exactly the same root as Step 93’s SQL Injection (mixing code and data). Just as a quote became grammar in SQL, here <script> became HTML grammar.

Why: this single alert is the target of hundreds of web problems in hacking competitions. Now that you’ve felt "input gets executed" on your own server, you’re ready to face this vulnerability head-on.

⚠️ Security connection: this experiment was performed only on my own server on my own computer. All attack practice stays in your own lab and legal platforms. Unauthorized attacks on real services are a crime. Putting this input into someone else’s site’s search box is not an experiment — it’s an attack attempt.

3-8. A Taste of Defense — One Line of Escaping

Having seen vulnerable code, let’s see the defense too. There’s a tool that safely transforms input.

Input

from markupsafe import escape

@app.route("/echo_safe")
def echo_safe():
    name = request.args.get("name", "")
    return f"<h1>your input: {escape(name)}</h1>"

Output (measured 2026-09-09, same attack input):

<h1>your input: &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;</h1>

How to read it: escape converts < into characters like &lt; that "only show on screen and can’t execute." On the browser screen, the literal text <script>alert('XSS')</script> shows, and it doesn’t execute. This is called escaping, and it’s the basic defense that neutralizes input at output time.

Why: knowing the attack and knowing the defense come as one set. Today you saw both XSS’s occurrence and its blocking within 30 lines — this contrast is why it sticks in memory.

Version note (measured 2026-09-09, Flask 3.1.3): the from flask import escape found in older materials now errors — ImportError: cannot import name 'escape' from 'flask'. It was removed in Flask 2.3, so the correct form is the one above, importing directly from markupsafe, the safety-processing tool Flask uses internally.


4. Missions & Exercises

Mission — Completing a Mini Guestbook Web App

  1. / — make a page that shows the list of messages left so far.
  2. /write — make a form that takes a name and a one-line message (GET display + POST handling).
  3. Store POSTed messages in a Python list and list them on /. But you must apply escape on output.
  4. Keep the deliberately vulnerable version (/echo), and secure side-by-side captures of XSS succeeding and of it failing after escape is applied.
  5. Capture a screen comparing the server console’s request log with the browser’s Network tab.
  6. Write Flask-first-server.md in your wiki — the routing concept / the GET·POST difference / 3 lines on why XSS happens.

Exercises

Exercise 1. Explain what the single decorator line @app.route("/hello") does, using the phrase "connecting an address to a function."

Exercise 2. GET parameters come via request.args and POST form data via request.form. Explain why a password field must be POST, not GET.

Exercise 3. If you submit a form with methods=["GET", "POST"] missing from the route, what status code comes back? Explain why.

Exercise 4. Explain the principle by which escape blocks XSS from the perspective of "mixing code and data."


Answers & completion criteria · expand/collapse

5. Model Answers & Completion Criteria

Mission Model Answer

Skeleton of the guestbook:

from flask import Flask, request
from markupsafe import escape

app = Flask(__name__)
guestbook = []   # list of (name, message) tuples

@app.route("/")
def home():
    items = "".join(
        f"<li><b>{escape(name)}</b>: {escape(msg)}</li>"
        for name, msg in guestbook
    )
    return f"<h1>Guestbook</h1><ul>{items}</ul><a href='/write'>Write</a>"

@app.route("/write", methods=["GET", "POST"])
def write():
    if request.method == "POST":
        name = request.form.get("name", "anonymous")
        msg = request.form.get("msg", "")
        guestbook.append((name, msg))
        return "Saved. <a href='/'>Back to list</a>"
    return '''
        <form method="post">
          <input name="name" placeholder="name">
          <input name="msg" placeholder="one-line message">
          <button>Leave it</button>
        </form>
    '''

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000)

How to verify: ① when you leave a post at /write, does it appear on /? ② even with <script>alert(1)</script> in a message, does it show only as text on / without executing (escape check)? ③ does the same input execute in the vulnerable version /echo — have you secured the two contrast captures? ④ are GET/POST logs stamped on the server console in time order? All "yes" means complete.

Exercise Answers

Answer 1. It’s a connection (routing) declaration saying "when a GET request comes to the address /hello, run the function right below and return its return value as the response body." Connecting addresses to functions is a web server’s essence, and Flask expresses it in one decorator line.

Answer 2. Because GET’s parameters show as-is in the address bar (URL) and remain in browser history and server logs — in the measurement too, the value got stamped whole in the server log like "GET /hello?name=hacker HTTP/1.1" (2026-09-09). Since a password must not remain in addresses and logs, we use POST, where it rides in the body.

Answer 3. 405 Method Not Allowed comes back (measured 2026-09-09: sending POST to a GET-only route gave 405 METHOD NOT ALLOWED). A Flask route allows only GET by default, and form submission is POST, so a refusal comes out saying "that method isn’t allowed at this address." The decorator’s methods and the form tag’s method="post" come as a set.

Answer 4. XSS happens when user input (data) gets delivered to the browser mixed in one bowl with HTML grammar (code). escape converts "characters that would become grammar" like <, >, ' into characters like &lt; that only display, leaving the input forever as data only. Just as SQL’s binding confines input to data, escaping neutralizes input at output time — the same root, a defense at a different position.

Completion Criteria Checklist

  • [ ] I can launch a server with two or more routes using Flask
  • [ ] I can receive and handle query strings and form POST respectively
  • [ ] I can make a JSON response by returning a dictionary
  • [ ] I can trace a request by comparing the browser’s Network tab with the server log
  • [ ] I can explain code where XSS occurs and its principle (code-data mixing)
  • [ ] I can demonstrate blocking XSS with escape
  • [ ] Mission: I completed the mini guestbook and the success/block contrast captures

6. Common Pitfalls & Fixes

Wall 1. I edited the code but the browser looks the same

Symptom: you clearly changed the return statement but the screen doesn’t change.

Cause: the server is running on old code. Either you ran it without debug=True, or restart detection failed.

Fix: stop the server with Ctrl+C in the terminal and run python app.py again. Check that debug=True is in the run statement.

Wall 2. "Address already in use" error appears

Symptom (output example — the message varies by environment): the server won’t start and says the port is in use.

Cause: a previously launched server is still alive (another terminal window, etc.).

Fix: find that window and press Ctrl+C. If you can’t find it, temporarily dodge by changing the port to 5001. Build the habit of always shutting down launched servers when practice ends.

Wall 3. Submitting the form gives "Method Not Allowed"

Symptom (measured 2026-09-09): pressing the submit button gives 405 METHOD NOT ALLOWED.

Cause: you didn’t write methods=["GET", "POST"] on the route. The default allows only GET.

Fix: add methods to the decorator. And check the form tag is <form method="post"> too — these two come as a set.

Wall 4. from flask import escape gives an ImportError

Symptom (measured 2026-09-09, Flask 3.1.3):

ImportError: cannot import name 'escape' from 'flask'

Cause: escape was removed in Flask 2.3. Following older materials, you hit this wall.

Fix: import it with from markupsafe import escape. That’s exactly the safety-processing tool Flask uses internally.

Wall 5. Korean input turns into %EA%B8%B8 in the address bar

Symptom: entering Korean in the address bar turns it into a sequence of %, numbers, and letters.

Cause: not an error — a URL can only hold English letters, digits, and some symbols, so the rest gets converted to % encoding (percent encoding). The server decodes it on its own (in the measurement too, name=gildong was pulled out correctly as Korean on the server).

Fix: leave it as is. If you want to confirm the value the server received, put print(name) inside the route and watch the console.


7. Summary

Today’s Concepts

Concept One-line explanation
Routing A declaration connecting a URL to a function — a web server’s essence
GET / POST A request attached to the address bar / a request with data in the body
Query string ?name=value — parameters attached after the URL
Status code 200 success / 404 no such address / 405 method not allowed
XSS A vulnerability where input executes as HTML grammar — code-data mixing
Escaping An output defense converting <&lt;-style non-executable characters
Dev/production server app.run() is for development only — real services use a separate server

Today’s Code

Code What it does
@app.route("/address") Connect an address to a function (routing)
methods=["GET", "POST"] Specify allowed methods
request.args.get("name", default) Pull out a GET query string
request.form.get("name") Pull out POST form data
return dictionary Auto-generate a JSON response
escape(string) XSS-prevention escape (import from markupsafe)
app.run(host="127.0.0.1", port=5000) Start a localhost development server

An Instinct More Important Than Commands

From today, having written a server yourself, you see the web with two faces. You know a parameter in the address bar arrives at one line of request.args, and you know what happens if that value gets loaded into HTML without escape. Every input arriving at the server may be contaminated — this sentence is web security’s first principle.

And even if the framework changes, the skeleton doesn’t. Django or FastAPI — routing, request objects, GET/POST stay the same, because HTTP doesn’t change. The five-line server you made today is a scale model of every web service.


Once every box is checked, Step 94 is complete.

ONE STEP FURTHER

Finished this lesson?

Check the completion criteria, then mark your progress.

Something wrong with this page or a link? Let us know.
Next