Server-side programming explained: HSC Software Engineering Module 2
“Implement server-side programming, including routing, handling requests, generating responses and integrating with a database”
A server-side handler routes a method+path pair, parses input (path, query, body), validates, talks to the database with parameterised queries, and returns a status code plus body. Authentication and object-level authorisation belong in the handler, not in the database layer alone.
Picture a busy restaurant kitchen taking phone orders. The router is the person answering the phone: they listen for what is being asked for (GET a menu, POST a new order) and pass the call straight to the right station. That station (the handler) checks the order makes sense before doing anything (input validation: did the caller actually say a real dish and a sane quantity, not "yes please to everything"), then walks to the pantry to fetch or update the real stock records (the database), always reading the pantry's own labels rather than trusting a scribbled note someone slipped under the door (a parameterised query, never a query built by pasting the caller's words straight in). Before any of this happens, the kitchen checks the caller is a genuine, verified customer and only lets them touch their own tab, never someone else's (authentication and object-level authorisation). Finally, the kitchen calls back with a clear result: order confirmed, order rejected because something was missing, or "sorry, kitchen fire" without ever describing exactly which gas main burst (status codes, without leaking a stack trace).
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}));
- 200 OK: successful GET/PUT/DELETE with a body to return.
- 201 Created: a POST that successfully created a new resource.
- 204 No Content: success with no body to return (e.g. after a DELETE).
- 400 Bad Request: the client sent malformed or invalid input.
- 401 Unauthorized: missing or invalid credentials.
- 403 Forbidden: authenticated, but not allowed to perform this action.
- 404 Not Found: resource does not exist, or the caller should not know it exists.
- 500 Internal Server Error: an unhandled bug; log it, never expose the stack trace.
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.
A 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.
When a handler is about to fail, ask in order: is the caller even who they say they are? (no, return 401). Are they allowed to do THIS specific thing to THIS specific resource? (no, return 403, or 404 if you want to hide that the resource exists at all). Is their input malformed regardless of who they are? (yes, return 400). Did YOUR code break unexpectedly? (yes, return 500, logged, never shown to the client). Working through this order stops the common exam mistake of returning 400 for an authentication failure or 404 for a validation failure.
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.
A developer writes:
@app.get("/notes/<id>")
def get_note(id):
conn = sqlite3.connect("notes.db")
query = f"SELECT * FROM notes WHERE id = {id}"
row = conn.execute(query).fetchone()
return str(row)
List four issues and fix them.
- SQL injection: the id is interpolated into the query. Fix with
?and a tuple. - No authentication: anyone can read any note. Add a session check.
- No object-level authorisation: a logged-in user can read another user's notes. Add
AND user_id = ?. - Connection leak:
sqlite3.connectis never closed on the error path. Usewith.
@app.get("/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])
- Returning raw database rows
- Leaking internal columns (password hashes, internal IDs) breaks confidentiality. Explicitly select and map the fields you mean to expose.
- Skipping the response status code
- Returning the JSON helper alone defaults to 200 even for creations and errors. Set 201 for created, 400 for client errors, 401 for auth, 500 for crashes.
- Building SQL with string concatenation
- Always parameterise. The single most common cause of breaches.
- Trusting the request body
- Validate every field. Set length limits. Reject unexpected types.
- Logging sensitive data
- Never log passwords, tokens or full credit card numbers. Logs leak.
Exam-style questions
Questions in the style of NESA exam questions on this dot point, each with a worked answer. They are written by ExamExplained unless tagged "Past paper"; the year shows the paper a question is modelled on.
Practice6 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):
- Authentication via Authorization header; the endpoint aborts with 401 if the token is missing or invalid.
- Input validation of title and body lengths; rejects empty or oversized input with 400.
- Parameterised query for the INSERT prevents SQL injection.
- 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 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.
- 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
fromaccount. - SQL injection. Both
amount,fromandtoare interpolated directly into SQL strings via f-strings, letting an attacker inject arbitrary SQL. - No input validation. There is no check that
amountis a positive number, or thatfromandtoexist, or thatfromhas sufficient balance. - 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.
- 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.