Skip to main content
ExamExplained
NSW · Software Engineering
Software Engineering study scene
§-Syllabus dot point
NSWSoftware EngineeringSyllabus dot point

Inquiry Question 1: How are secure web applications developed?

Implement server-side programming, including routing, handling requests, generating responses and integrating with a database

A focused answer to the HSC Software Engineering Module 2 dot point on server-side programming. Routing, handlers, response building, database integration, the worked Flask example, and the traps markers look for.

Reviewed by: AI editorial process; not yet individually human-reviewed

Have a quick question? Jump to the Q&A page

What this dot point is asking

NESA wants you to write a server-side endpoint that routes a request, parses its body, validates input, talks to a database, and returns a response. Use any language/framework you are comfortable with - Python with Flask is the most common HSC choice.

The answer

Routing

A server-side framework maps HTTP method + path to a handler function:

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.get("/api/notes")
def list_notes():
    return jsonify([])  # placeholder

@app.post("/api/notes")
def create_note():
    return jsonify(id=1), 201

@app.get("/api/notes/<int:id>")
def get_note(id):
    return jsonify(id=id, title="example")

In Node with Express:

import express from "express";
const app = express();
app.use(express.json());

app.get("/api/notes", (req, res) => res.json([]));
app.post("/api/notes", (req, res) => res.status(201).json({id: 1}));
app.get("/api/notes/:id", (req, res) => res.json({id: req.params.id}));

Reading input

Three places input arrives:

  • Path parameters (/api/notes/<id>): identifiers and resources.
  • Query string (/api/notes?since=2026-01-01): filters and pagination.
  • Request body (POST/PUT): the payload, typically JSON.
@app.get("/api/notes")
def list_notes():
    since = request.args.get("since")
    limit = int(request.args.get("limit", 50))
    # ...

Validating input

Always validate before using:

@app.post("/api/notes")
def create_note():
    data = request.get_json(silent=True) or {}
    title = (data.get("title") or "").strip()
    if not (1 <= len(title) <= 200):
        abort(400)
    # ... safe to use title ...

Use a schema library (Pydantic, Zod, Marshmallow) for anything beyond trivial validation.

Database integration

Use parameterised queries (covered in detail in databases-and-sql):

@app.get("/api/notes")
def list_notes():
    user_id = current_user(request.headers)
    if not user_id:
        abort(401)
    with sqlite3.connect("notes.db") as conn:
        rows = conn.execute(
            "SELECT id, title FROM notes WHERE user_id = ? ORDER BY id DESC",
            (user_id,),
        ).fetchall()
    return jsonify([{"id": r[0], "title": r[1]} for r in rows])

Building responses

A response has three parts: status code, headers, body. The framework usually handles the headers for you:

return jsonify(error="not found"), 404, {"X-Trace-Id": "abc"}

Return JSON for APIs (application/json). Return HTML for traditional web pages (text/html).

Authentication and authorisation

Most endpoints need to confirm the requester is logged in and then verify they own the resource they are touching. A simple decorator pattern works well in Flask: the decorator authenticates the request and attaches the user identifier so the handler can use it directly.

def require_login(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        user_id = current_user(request.headers)
        if not user_id:
            abort(401)
        request.user_id = user_id
        return f(*args, **kwargs)
    return wrapper

@app.delete("/api/notes/<int:id>")
@require_login
def delete_note(id):
    with db() as conn:
        result = conn.execute(
            "DELETE FROM notes WHERE id = ? AND user_id = ?",
            (id, request.user_id),
        )
        if result.rowcount == 0:
            abort(404)
    return "", 204

The user_id = ? in the WHERE clause does object-level authorisation - a logged-in user cannot delete someone else's notes by guessing their id.

An owned pipeline diagram shows the fixed order these checks happen in, and why a request must be stopped the moment it fails any single stage rather than being allowed to continue.

The server-side request pipeline A left to right pipeline diagram showing an incoming request passing through five stages in order: routing, which matches method and path to a handler; authentication, which verifies the caller's identity and rejects with 401 if invalid; validation, which checks the input and rejects with 400 if malformed; the database, accessed only with parameterised queries scoped to the authenticated user; and finally the response, which returns a status code and JSON body. A rejected request at authentication or validation exits immediately without reaching the database. Routing method + path to a handler Authentication verify identity else 401, stop Validation check input shape else 400, stop Database parameterised query scoped to user_id Response status + JSON body e.g. 201, 200, 404 401 exits immediately 400 exits immediately A request that fails authentication or validation is rejected on the spot; it never reaches the database. Only requests that pass every earlier gate touch the database with a trustworthy, scoped user_id.

Errors

Distinguish:

  • 400 Bad Request: client sent malformed input.
  • 401 Unauthorized: missing or invalid credentials.
  • 403 Forbidden: authenticated but not allowed.
  • 404 Not Found: resource does not exist (or the user is not allowed to know it exists).
  • 500 Internal Server Error: a bug. Log it; do not leak the stack trace to the user.

A full slice

The complete login endpoint below combines routing, request parsing, input validation, parameterised SQL, hashed password verification, token issuance and correct status codes. This is the standard shape of a back-end endpoint.

from flask import Flask, request, jsonify, abort
import sqlite3, bcrypt

app = Flask(__name__)

@app.post("/api/login")
def login():
    data = request.get_json(silent=True) or {}
    username = (data.get("username") or "").strip()
    password = (data.get("password") or "").encode()
    if not username or not password:
        abort(400)
    with sqlite3.connect("app.db") as conn:
        row = conn.execute(
            "SELECT id, password_hash FROM users WHERE username = ?",
            (username,),
        ).fetchone()
    if not row or not bcrypt.checkpw(password, row[1]):
        abort(401)
    token = issue_token(row[0])
    return jsonify(token=token)

This single endpoint demonstrates routing, input parsing, validation, parameterised SQL, hashed password verification, and correct status codes.

Exam-style practice questions

Practice questions written in the style of NESA exam questions on this dot point, with worked answer explainers. The year tag is the paper they imitate, not the source.

2024 HSC6 marksWrite a server-side endpoint that accepts a POST request to /api/notes containing JSON with 'title' and 'body' fields, stores the note in a database, and returns the new note's id. Identify two security controls applied in your code.
Show worked answer →
from flask import Flask, request, jsonify, abort
import sqlite3

app = Flask(__name__)

def db():
    conn = sqlite3.connect("notes.db")
    conn.row_factory = sqlite3.Row
    return conn

def current_user(headers):
    token = headers.get("Authorization", "").replace("Bearer ", "")
    return verify_token(token)

@app.post("/api/notes")
def create_note():
    user_id = current_user(request.headers)
    if not user_id:
        abort(401)
    data = request.get_json(silent=True) or {}
    title = (data.get("title") or "").strip()
    body = (data.get("body") or "").strip()
    if not (1 <= len(title) <= 200) or not (1 <= len(body) <= 5000):
        abort(400)
    with db() as conn:
        cur = conn.execute(
            "INSERT INTO notes (user_id, title, body) VALUES (?, ?, ?)",
            (user_id, title, body),
        )
        return jsonify(id=cur.lastrowid), 201

Security controls (any two):

  1. Authentication via Authorization header; the endpoint aborts with 401 if the token is missing or invalid.
  2. Input validation of title and body lengths; rejects empty or oversized input with 400.
  3. Parameterised query for the INSERT prevents SQL injection.
  4. Per-user isolation: each note is stored against user_id, so subsequent GET endpoints can scope to the owner.

Markers reward a real route definition, a request body parse, input validation, a parameterised database call, a correct status code (201 for creation), and at least two named security controls.

Practice questions

Original practice questions graded from foundation to exam level, each with a full worked solution. Try them before revealing the solution.

foundation2 marksState the HTTP status code a server should return for each: (a) a GET request for a note that does not exist, (b) a successful login.
Show worked solution →

(a) 404 Not Found. (b) 200 OK (the login succeeded and a token/body is returned, but no new resource was created, so 201 is not appropriate here).

Marking criteria: 1 mark for each correct status code with a brief justification.

foundation3 marksWrite a Flask route that responds to a GET request at /api/health and returns the JSON body {"status": "ok"} with status code 200.
Show worked solution →
@app.get("/api/health")
def health():
    return jsonify(status="ok"), 200

Marking criteria: 1 mark for the correct route decorator and path, 1 mark for returning the correct JSON body, 1 mark for the explicit 200 status code.

core4 marksThe diagram below shows a request travelling through routing, authentication, validation and the database. Using the diagram, explain what should happen to a request that fails the authentication step, and why it must not reach the database layer.
Show worked solution →

As the diagram shows, authentication sits before the database layer in the request pipeline. A request that fails authentication (no valid token, or an invalid one) should be rejected immediately with a 401 Unauthorized response, and the handler should stop processing right there, never continuing on to validation or the database query.

This matters because every later stage assumes a known, verified user identity: validation rules and, critically, object-level authorisation checks (such as WHERE user_id = ?) rely on user_id being trustworthy. If an unauthenticated request were allowed to reach the database layer, an attacker could potentially read or modify data belonging to any user, since there would be no verified identity to scope the query against.

Marking criteria: 1 mark for stating the correct response (401, request halted), 1 mark for correctly placing authentication before the database layer using the diagram, 1 mark for explaining that later stages depend on a trustworthy user identity, 1 mark for linking this to preventing unauthorised data access.

core4 marksIdentify the vulnerability in the following handler and rewrite it safely: `@app.get("/api/notes/<id>") def get_note(id): row = conn.execute(f"SELECT * FROM notes WHERE id = {id}").fetchone(); return str(row)`.
Show worked solution →

This handler is vulnerable to SQL injection because the id path parameter is interpolated directly into the SQL string using an f-string. A request such as /api/notes/1 OR 1=1 (or a more malicious payload using UNION SELECT) changes the structure of the query, potentially returning every row in the table or data from other tables entirely.

Safe rewrite:

@app.get("/api/notes/<int:id>")
@require_login
def get_note(id):
    with sqlite3.connect("notes.db") as conn:
        row = conn.execute(
            "SELECT id, title, body FROM notes WHERE id = ? AND user_id = ?",
            (id, request.user_id),
        ).fetchone()
    if not row:
        abort(404)
    return jsonify(id=row[0], title=row[1], body=row[2])

The ? placeholder passes id as a bound parameter rather than as literal SQL text, so it can never change the query's structure. The added user_id = ? also fixes the missing object-level authorisation, and explicit column selection avoids leaking unintended fields.

Marking criteria: 1 mark for correctly naming SQL injection, 1 mark for explaining how the f-string enables it, 1 mark for a correct parameterised rewrite, 1 mark for also addressing object-level authorisation and column exposure.

core5 marksData table: an endpoint receives 500 requests in a minute, of which 20 fail validation, 15 fail authentication, and 5 cause an unhandled server exception. Calculate how many requests should receive each of 400, 401 and 500 responses, and state what should never appear in the body of the 500 responses.
Show worked solution →

Of the 500 requests: 20 fail validation, so 20 responses should be 400 Bad Request. 15 fail authentication, so 15 responses should be 401 Unauthorized. 5 cause an unhandled server exception, so 5 responses should be 500 Internal Server Error.

The remaining 500−20−15−5=460500 - 20 - 15 - 5 = 460 requests succeed and receive a 2xx response (200 or 201 depending on the operation).

The body of the 500 responses must never contain the raw stack trace, internal file paths, database connection strings, or any other implementation detail; the server should log these details internally for the developers and return only a generic error message (e.g. "Internal server error") to the client.

Marking criteria: 1 mark for the correct count of 400 responses (20), 1 mark for 401 responses (15), 1 mark for 500 responses (5), 1 mark for correctly computing the successful count (460), 1 mark for correctly stating that stack traces/internal details must not be exposed in the 500 body.

exam7 marksA junior developer submits this endpoint for review: `@app.post("/api/transfer") def transfer(): data = request.get_json(); conn.execute(f"UPDATE accounts SET balance = balance - {data['amount']} WHERE id = {data['from']}"); conn.execute(f"UPDATE accounts SET balance = balance + {data['amount']} WHERE id = {data['to']}"); return "ok"`. Evaluate the endpoint and rewrite it to be production-safe, explaining each change.
Show worked solution →

Issues to evaluate.

  1. No authentication or authorisation. Anyone can call this endpoint and move money out of any account into any other account; there is no check that the caller owns the from account.
  2. SQL injection. Both amount, from and to are interpolated directly into SQL strings via f-strings, letting an attacker inject arbitrary SQL.
  3. No input validation. There is no check that amount is a positive number, or that from and to exist, or that from has sufficient balance.
  4. Not atomic. The two UPDATE statements are not wrapped in a single transaction; if the process crashes between them, money can be deducted from one account without being credited to the other.
  5. Unhelpful response. Returning the bare string "ok" gives no status code and no way for the client to confirm what happened.

Rewritten endpoint.

@app.post("/api/transfer")
@require_login
def transfer():
    data = request.get_json(silent=True) or {}
    amount = data.get("amount")
    to_id = data.get("to")
    if not isinstance(amount, (int, float)) or amount <= 0:
        abort(400)
    if not isinstance(to_id, int):
        abort(400)
    from_id = request.user_id
    with db() as conn:
        row = conn.execute(
            "SELECT balance FROM accounts WHERE id = ?", (from_id,)
        ).fetchone()
        if not row or row[0] < amount:
            abort(400)
        conn.execute("BEGIN")
        conn.execute(
            "UPDATE accounts SET balance = balance - ? WHERE id = ?",
            (amount, from_id),
        )
        conn.execute(
            "UPDATE accounts SET balance = balance + ? WHERE id = ?",
            (amount, to_id),
        )
        conn.execute("COMMIT")
    return jsonify(status="transferred"), 200

Why each change fixes an issue. @require_login and using request.user_id for from_id stop an attacker moving funds out of an account they do not own. Parameterised ? placeholders remove the SQL injection vector entirely. Explicit type and value checks on amount and to reject malformed or negative transfers before touching the database. Wrapping both UPDATE statements in a single BEGIN/COMMIT transaction makes the transfer atomic, so a crash between the two statements leaves the database unchanged rather than half-applied. Returning a JSON body with an explicit 200 status gives the client a machine-readable confirmation.

Marking criteria: 1 mark for identifying the missing authentication/authorisation, 1 mark for identifying SQL injection, 1 mark for identifying missing input validation, 1 mark for identifying the non-atomic transaction risk, 1 mark for a correct parameterised and validated rewrite, 1 mark for correctly wrapping the two updates in a transaction, 1 mark for a correct, informative response.

ExamExplained