Test and operate
Build a security test plan
Turn routes, roles, data flows, and threat stories into a repeatable plan that covers prevention and safe failure.
A generic security checklist helps a little. Your own application’s trust boundaries help a lot more. Start from what would actually hurt if it broke, not from a list someone wrote for a different app.
Map the surface first
Before writing a single test, list where trust changes hands: public routes, authenticated actions, roles, sensitive records, uploads, outbound requests, background workers, and admin functions. Each item is a boundary, and each boundary deserves at least one test.
I do this in a plain text file. It takes half an hour for a mid-sized app and it’s the most useful half hour of the whole exercise, because it’s where you notice the export worker nobody mentioned.
Turn boundaries into rows
For each feature, write a row that pins down the precondition, the exact request, and every expected outcome. Not just the status code. The state of the data and the log line too:
Feature: update note
Precondition: Alice logged in, note 42 owned by Bob
Request: PUT /notes/42 { "title": "x" }
Expected: 403, note 42 unchanged, one authorization-failure event
A test is repeatable only when the expected state is written down. “It looked fine” is not a result. Three months from now, a different person should be able to run this row and know whether it passed.
Then go past the happy path. For one feature, cover normal use, misuse, missing proof, odd encodings, and limits:
- valid update by owner -> 200, row changed
- update by non-owner -> 403, row unchanged
- update with no session -> 401
- oversized body -> 413
- id as ../ or SQL characters -> rejected, no error leak
Running these is mostly curl. Here’s the second row:
curl -s -o /dev/null -w '%{http_code}\n' -b 'session=alice' -X PUT \
-H 'Content-Type: application/json' -d '{"title":"x"}' \
https://app.flaviocopes.com/notes/42
# 403
Then check the database: note 42 still has Bob’s title. Then check the log: one authz.denied event. Three checks, one row.
The boundary a checklist misses
Here’s the kind of gap this method finds. A team’s plan covers login, forms, and every API route. It passes. But the nightly export worker reads a user ID from a queue message and trusts it. A crafted message makes the worker write another tenant’s data into the archive. No route was involved, so no generic checklist asked about it.
The worker only shows up when you map the surface yourself and include “background workers” as a boundary. That’s why the map comes first.
Two more habits. Include failure paths in the plan, like “what does the export do when storage is down”, because leaks hide in error handling. And keep the evidence you collect free of live secrets. A test plan full of real session cookies is its own security problem.
Try this on your own project: build a table with precondition, request, expected response, expected state, and expected event for one feature. Make sure one row covers a background job or a failure path. Run the rows with the highest impact first and attach what you saw.
Lesson completed