Introduction: security is no longer "the specialists' job"
If you build web applications, scenes like these are probably becoming familiar:
- You wrote the login flow and the admin pages yourself. But security checks keep slipping, because "there's nobody here who really knows that stuff."
- You let AI write more of your code now. Working software arrives faster, but you have no way to judge whether that code is safe.
- You promote AI adoption or digitalization inside your company, and when someone asks "is the security OK?", you are not sure what "OK" would even be based on.
Few organizations have a dedicated security person. Yet when an incident happens, the people who respond are the ones who built the system or promoted it. For many engineers today, the first problem is not learning advanced attack techniques. It is having a way to verify that the defenses you put in actually work.
That verification step is where things get fuzzy. You can say "I added an authorization check" or "I set attributes on the cookie," but if the condition for "checked" is vague, you end up with "the scan passed, but I can't explain what it guaranteed." Done in name only — I meant to have it covered.
Web Security Control Lab (below, "the lab") is a small learning system for practicing that verification step. It is not a diagnostic tool for security professionals. It is teaching material for engineers who build web apps or drive technology adoption in their organizations, so they can try things safely on their own machine and learn the basic pattern of defense plus verification.
This article first explains what the lab is for and how I would like you to use it, and then follows the implementation on both the application side and the scanner side, in code. The technical content is not watered down. Terms are explained where they first appear, so if security is not your specialty, read it while mapping the ideas onto your own application.
Repository: https://github.com/moruku36/web-security-control-lab
Safety premise: This lab is for learning only. The app binds to 127.0.0.1 only; never expose it to the internet or a shared network. As described later, the scanner refuses non-local targets. Do not use real data or real passwords, and never scan systems that belong to someone else. SECURITY.md
This article covers commit 4dc4d9d (main as of October 22, 2026). I verified the runs myself on October 24, 2026, in an isolated Python 3.12.3 environment. Links are pinned to this commit unless noted otherwise.
What this lab is for
The lab's basic loop is observe → detect (scanner) → analyze the cause → fix → re-verify. Instead of memorizing terms in a classroom style, you touch an app that has weaknesses, find them with a check, think about why they happen, and compare against the fixed state. Experiencing that whole loop on a small subject is the point.
The setup comes in pairs:
- A FastAPI app that deliberately leaves weaknesses in (
VULNERABLEmode), and the same app with defenses applied (HARDENEDmode) - A scanner (CLI) that applies the same six conditions to both modes and returns
PASS/FAIL/ERROR
Why bundle "a weak app," "a fixed app," and "a checking tool"? Noticing weaknesses takes practice, and a fixed app alone does not show you what changed. With the checking tool in the middle, "which conditions must hold before we may call it fixed" is decided up front, and the learning does not end at "I sort of get it."
I imagine three ways of using it:
| Usage | What you do | What you get |
|---|---|---|
| 1. Compare first | Start both modes, run the same checks, compare the results | The overall picture of what changes with and without defenses |
| 2. Go deep on one | Follow the LAB-01 worksheet: predict → observe → explain the cause → fix → re-verify | One theme — authorization — understood to the point where you can explain it |
| 3. Apply it to your own app | Use the six check conditions as a checklist and review your own app's login, admin pages, and logs | The habit of thinking about defense and verification as a pair |
Just as important is what the lab is not. It is not a tool for assessing production services, and its results are not proof that a production system is safe (see "Limitations"). It is not for probing other people's systems either: the scanner is designed to refuse non-local targets in the first place.
The whole picture: only three parts
Figure 1: Overall architecture. Colors: blue = learner, green = scanner, purple = target app, orange = launcher, yellow = data. Rendered from the repository's Mermaid source with Pele 1.8.0.
| Element | What it is | Notes |
|---|---|---|
| Target app | FastAPI (app/), one process |
Takes exactly one LAB_MODE per start |
| Data | SQLite (inside the app process) | In-memory by default; changeable via LAB_DB_PATH
|
| Scanner | Host-side CLI security-lab (scanner/) |
Checks over HTTP only; never opens SQLite directly |
The key point: the scanner does not peek inside the app (its database). It accesses the app from outside, over HTTP — the same vantage point an attacker or a user has — which makes the perspective close to real-world practice: "how does this look from the outside?"
Start the app with uvicorn on the host, or with Docker Compose's single web service (VULNERABLE, 127.0.0.1:8000). There is no database container and no scanner container. The mode is read from the LAB_MODE environment variable at startup and never changes during the process's life. To compare both modes, start separate processes on separate ports.
Try it: the smallest run
So this does not stay read-only, here is the minimal procedure. You need Python 3.11+ and Git (commands below are for Bash; on Windows PowerShell, only the way you set environment variables differs — noted below).
git clone https://github.com/moruku36/web-security-control-lab.git
cd web-security-control-lab
git checkout 4dc4d9dc1adfdf9cfcf07abf3593f04a5722d886
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
The commit is pinned so everyone works on the version this article covers. This state (detached HEAD) is fine for reading and running. If you want to practice fixing things yourself, first create a working branch, e.g. git switch -c my-lab-practice.
Use two terminals to start both modes:
# Terminal 1: the mode that leaves weaknesses in
LAB_MODE=VULNERABLE uvicorn app.main:app --host 127.0.0.1 --port 8000
# Terminal 2: the mode with defenses applied
LAB_MODE=HARDENED uvicorn app.main:app --host 127.0.0.1 --port 8001
On PowerShell, set the variable before starting uvicorn, like $env:LAB_MODE="HARDENED". The mode is read at startup, so changing it requires a server restart.
In a third terminal, confirm startup and mode, then scan:
curl -s http://127.0.0.1:8000/health
curl -s http://127.0.0.1:8001/health
security-lab scan http://127.0.0.1:8000 --format json --expected-mode VULNERABLE
security-lab scan http://127.0.0.1:8001 --format json --expected-mode HARDENED
Check that the mode in the /health JSON is what you expect before going on. --expected-mode tells the scanner to confirm the app is in the intended mode; if it disagrees, every check becomes ERROR. Pass the base URL — do not append a path like /admin.
Trying it in a browser is also worthwhile. Log in as the regular user alice / user and open the admin page /admin: in VULNERABLE it is displayed; in HARDENED it is rejected with 403. But when comparing both modes in the same browser, use separate profiles, or test one mode at a time and clear cookies. Cookies are not separated by port, so separate tabs alone can make sessions interfere (RFC 6265 §8.5).
How to read the results (just this, to start)
Results are three-valued:
-
PASS: the check's condition was met -
FAIL: a weakness was observed -
ERROR: a prerequisite could not be confirmed, so the check did not complete. This is not success
When the checks run properly, the expected results are: VULNERABLE → 6 FAIL (HIGH 1 / MEDIUM 4 / LOW 1), HARDENED → 6 PASS (findings 0). Watch the exit codes, too. 0 means "the report completed" — it is 0 even when the report contains FAILs. "Exit code 0 = no problems" is a misreading we will come back to later.
The six checks: all of them show up in "ordinary apps"
None of the six targets is exotic. Any web app with a login has all of these elements in daily use. First, where each one appears in your own app:
| ID | Target | Everyday scene | If it is weak (summary) |
|---|---|---|---|
| LAB-01 | Authorization on the admin page | Admin screens, approval features | Someone who is not an admin can perform admin operations |
| LAB-02 |
HttpOnly on the session cookie |
Anything with a login | A login state becomes easier to abuse via scripts |
| LAB-03 |
SameSite on the session cookie |
Logins, integrations with external sites | A login state becomes easier to ride on unintended requests |
| LAB-04 | Security headers | Every screen and API | Browser-side defenses do not engage |
| LAB-05 | Information exposure in error responses | API error handling | Clues for inferring the internal structure |
| LAB-06 | Recording failed logins | Logins, operational monitoring | Malicious attempts happen and nobody notices |
Two terms, briefly. Authentication establishes who you are (logging in). Authorization decides whether you may do this operation. Being logged in and being allowed to see the admin page are different things, and LAB-01 targets exactly that confusion. A cookie is a small piece of data stored by the browser and automatically attached to requests when its conditions match. Carrying the login-state identifier in a cookie is a widespread pattern, and its "read restriction (HttpOnly)" and "sending conditions (SameSite)" are what LAB-02 and LAB-03 examine.
Figure 2: Each control guards a different boundary and has different check points. Colors: blue = browser side, purple = server side, green = scanner, red = range not verified by these checks.
The app side: where do the defenses live?
How the mode branches
The mode is read from the environment in app/config.py, and each feature branches on that value. The weaknesses are not "misconfigurations": they are written into the code explicitly, with a marker comment INTENTIONAL_VULNERABILITY_FOR_LOCAL_LAB, so that reviewers can distinguish "intentional weakness" from "accident." Marking places that are deliberately so is an idea that transfers to production code in reviews, too.
The endpoints:
| Path | Role | What changes by mode |
|---|---|---|
GET /health |
Returns status and mode | Nothing (used for the scanner's prerequisite check) |
GET/POST /login, GET /logout
|
Authentication; issue and discard sessions | Whether failures are logged (LAB-06) |
GET /profile, GET /api/profile
|
Login-required page / the same content as JSON | Nothing |
GET /admin |
Admin page | Whether the role is checked (LAB-01) |
GET /api/diagnostic |
Diagnostic error response | Whether internal information is exposed (LAB-05) |
GET /audit-logs |
Returns the audit log | Nothing (unauthenticated in both modes — a limitation, below) |
The practice accounts are fixed plaintext fixtures (regular user alice / user, administrator admin / admin). They are teaching data, unrelated to how credentials should be handled in production.
LAB-01: check authorization at the endpoint itself
In the /admin implementation (app/main.py), an unauthenticated visitor is sent back to login with a 303 in both modes. The difference comes next: in HARDENED, anyone whose role is not admin gets 403; in VULNERABLE, that check simply does not exist.
if LAB_MODE == LabMode.HARDENED.value:
if user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: Administrator role required.",
)
A common real-world failure is "regular users never see a link to the admin screen, so it's fine." Even if the link is invisible, a direct request to the URL still arrives. What this implementation shows is the principle: the check belongs at the endpoint itself, and that is where you refuse. When you build admin features in your own app, check this first.
The expected observations:
| Observation | VULNERABLE | HARDENED |
|---|---|---|
/admin while logged out |
303 back to login | 303 back to login |
/admin as a regular user |
200, page displayed | 403, refused |
/admin as an administrator |
200, displayed | 200, displayed |
/admin with the old token after logout |
303 back to login | 303 back to login |
Note that not everything differs between the modes. The only difference is regular-user access; invalidating the old token after logout is correct behavior shared by both modes, placed there on purpose. It is practice in distinguishing "the state after a defense" from "the parts that were already right."
Sessions and cookies
On a successful login, a token is issued with secrets.token_hex(24), a record is created in the sessions table, and the token is placed in the lab_session cookie (app/auth.py). Logout deletes the record from the sessions table, so the old token becomes invalid in both modes.
The cookie attributes branch by mode: HARDENED sets httponly=True and samesite="lax"; VULNERABLE sets neither (LAB-02, LAB-03). Note that secure=False is a deliberate choice for HTTP on localhost, shared by both modes (see "Limitations"). Rather than leaving everything to framework defaults, check once which attributes your session cookie actually carries — in many apps, that alone surfaces review points.
Headers, errors, logs
-
LAB-04 (security headers): a mechanism for attaching instructions to responses — "browser, behave like this." Only in
HARDENED, HTTP middleware addsContent-Security-Policy: default-src 'self',X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, andX-Frame-Options: DENY. Whether they are configured shows up directly in the check result. -
LAB-05 (error responses):
/api/diagnosticreturns, inVULNERABLE, a 500 JSON containing a mock traceback and runtime-environment information; inHARDENED, it is narrowed down to a generic message and anerror_code. Detailed errors are convenient during development, but returning them to users in production gives clues about the internal structure. Every exposed value is mock teaching data, not a real secret. -
LAB-06 (audit log): successful authentication is always recorded as
AUTH_SUCCESS, butAUTH_FAILUREon failure is recorded only inHARDENED. If you record only successful operations and not failures, attempts such as password spraying cannot be detected when they happen. Not recording failures is itself treated as the weakness here.
The scanner side: what does a PASS actually prove?
Now the checking side — especially relevant if you want to add automated checks to your own app, or if you have to read a vendor's assessment results.
The scanner's six checks (scanner/checks.py) judge by these conditions:
| ID | Target | PASS condition | Example FAIL reason codes | Severity |
|---|---|---|---|---|
| LAB-01 | Authorization on /admin
|
Anonymous → login; regular user → 403; admin → displayed; old token after logout → invalid |
USER_ADMIN_ACCESS, etc. |
HIGH |
| LAB-02 | Session cookie | Has the HttpOnly attribute |
COOKIE_ATTRIBUTE_MISSING |
MEDIUM |
| LAB-03 | Session cookie | Exactly one SameSite attribute, with value Lax or Strict
|
COOKIE_ATTRIBUTE_MISSING / COOKIE_SAMESITE_VALUE_INVALID
|
MEDIUM |
| LAB-04 | Response headers of /health
|
CSP, X-Content-Type-Options, and Referrer-Policy exist and are non-empty | HEADER_MISSING |
MEDIUM |
| LAB-05 | /api/diagnostic |
Returns a generic error without internal information | DIAGNOSTIC_EXPOSURE |
LOW |
| LAB-06 | Audit log | The AUTH_FAILURE count increases across a failed login |
AUTH_FAILURE_NOT_LOGGED |
MEDIUM |
Never let ERROR become success
Each check's result is three-valued: PASS (condition met), FAIL (weakness observed), ERROR (a prerequisite could not be confirmed; incomplete). The important rule: when a prerequisite collapses, do not pronounce on the weakness — fall to ERROR. A common failure of automated checks is a check that "didn't really run" turning into "no problem found." This lab does the opposite, thoroughly.
For example, LAB-01 does not start by hitting /admin. It first logs in with the synthetic accounts and confirms via /api/profile that the identity and role are as expected (that confirmation stays in the evidence as verified), and only then makes its four observations: anonymous, regular user, administrator, and the old token after logout. If the login prerequisite cannot be established, the check is ERROR. When several weaknesses are observed, the primary reason code is chosen by priority: anonymous access > regular-user access > session retained.
LAB-03's condition is strict, too. It looks only at the Set-Cookie header for lab_session (other cookies' SameSite is ignored), requires exactly one attribute, and — ignoring letter case and surrounding whitespace — accepts only Lax / Strict. If attributes are duplicated, or there are multiple target headers so the effective value is indeterminate, the result is ERROR, not FAIL. Note that SameSite=None means "does not meet this lab's condition"; it is not a general judgment that it is dangerous. What browsers do when the attribute is unspecified is also outside this check's scope.
Conversely, some checks are deliberately narrow. LAB-04 looks only at the presence and non-emptiness of headers; it does not evaluate how strong the CSP's content is. LAB-06 looks only at whether the count increased; a strict correspondence between a specific attempt and a log entry, or alerting, is out of scope. A PASS means only "that check's condition was met" — nothing more is claimed. That is the design policy. If you read results with the same discipline, you will neither over-read nor under-read vendor reports and automated assessments.
Minimize the evidence
What remains in a report's evidence (schema 2.0) is restricted by an allowlist to actor (anonymous / user / admin / user_old_token), endpoint, status code, and permitted redirect targets — at most 16 observations — plus fixed reason codes. Messages are fixed templates per reason code; response bodies and exception strings never leak into a report. The validation in scanner/models.py enforces this. It is a design so that the checking tool itself does not take in the target's internal information as "evidence." When you share check results internally or paste them into tickets, whether this care exists changes how easy they are to handle.
The scanner's own safety design: if the target is wrong, it stops before sending
Figure 3: Verification flow. If the prerequisites cannot be established, it ends in ERROR and is never treated as success. Colors: green = scanner processing and the HARDENED expectation, orange = the VULNERABLE expectation, red = abnormal termination (refusal / ERROR).
"It's a checking tool, so it may check anything" is not true. Target validation is fail-closed — when in doubt, stop (scanner/target.py, scanner/cli.py).
- Permitted hosts are only
localhost, loopback IPs (judged withipaddress), and the Compose service name. Anything else is refused before any request is sent - Target URLs are accepted only in base-URL form (scheme + host [+ port]); URLs with credentials, paths, queries, or fragments are refused, and a refused URL is not echoed into the error message
- The HTTP client ignores proxy environment variables (
trust_env=False), does not follow redirects, and uses a 5-second timeout - The only credentials used are the synthetic fixtures. At scan start,
/healthconfirms the service name and mode; if that disagrees with--expected-mode, every check becomes ERROR (MODE_MISMATCH)
As more work is delegated to AI agents and automation tools, designs like "if the target is wrong, stop before sending" and "targets outside your authority are simply unreachable" apply to automation in general, not only to checking tools.
Exit codes — the key point when wiring this into automation (CI):
| Exit code | Meaning |
|---|---|
| 0 | Report completed (0 even when it contains FAILs) |
| 1 | No subcommand |
| 2 | Target refused, or argument error |
| 3 | Inspection incomplete (contains ERROR), or runtime error |
In other words, "exit code 0" does not mean "no weaknesses." Judge pass/fail from the report's status_summary and findings. If you build your own checks into CI, you need the same separation between "the tool ran successfully" and "the checks passed."
Verified results
Results I confirmed in my environment (Python 3.12.3, commit 4dc4d9d, October 24, 2026). I started both modes exactly as in this article's procedure, and the scan results matched the expectations.
- Tests: all 126 passed (unit / integration / vulnerabilities)
-
VULNERABLE(port 8000): 6 FAIL, findings HIGH 1 / MEDIUM 4 / LOW 1, exit code 0 -
HARDENED(port 8001): 6 PASS, findings 0, exit code 0 - I also confirmed that deliberately mismatching
--expected-modemakes every check ERROR (MODE_MISMATCH) with exit code 3, and that targetinghttps://example.comis refused before any request, with exit code 2
The text-format report for VULNERABLE looks like this (excerpt):
Web Security Control Lab
Target: http://127.0.0.1:8000 (mode reported by target: VULNERABLE)
Scope: six local educational checks only; not a production or compliance assessment.
[HIGH] LAB-01 Broken Access Control
/admin is accessible by a non-admin user
[MEDIUM] LAB-02 Session Cookie
HttpOnly flag is missing
[MEDIUM] LAB-03 Session Cookie
SameSite policy is missing
[MEDIUM] LAB-04 Security Header
Content-Security-Policy is missing
[LOW] LAB-05 Information Disclosure
Detailed error information is exposed
[MEDIUM] LAB-06 Security Logging
Failed authentication attempt was not recorded
The repository's CI includes a job that runs live checks against both modes, and the run for the target commit succeeded (CI run, confirmed October 22, 2026).
Limitations: HARDENED does not mean "safe in production"
This is easy to misunderstand, so let me be explicit. HARDENED means only "the state with the six lab controls applied." As the README states, the following constraints remain in both modes:
- Fixed plaintext test credentials, sessions with no expiry, logout via
GET, and an unauthenticated/audit-logs - No
Secureon cookies (premised on HTTP on localhost) - The checks are observations by an HTTP client, not verification of real browser behavior. Confirming cookie attributes does not prove safety against cross-site operations
- CSRF defenses themselves (tokens, etc.) are not among the lab's controls, and the scanner does not drive a browser to attempt CSRF (MDN's CSRF explainer)
- Passing the six checks proves neither production safety nor this material's educational effectiveness (educational effectiveness is unevaluated)
For a real service you would additionally need HTTPS enforcement, hashed password storage, session expiry and revocation design, CSRF tokens, access control and retention for logs, CSP content design, and more. The lab's scope ends at the entrance: putting "the basic six" into a checkable form.
How to use this in your own work
Finally, how to apply it, by reader:
If you develop web applications. When you add a feature, use the six conditions as review viewpoints as they are. Added an admin feature? Try the same URL as logged-out / regular user / administrator. Added a login? Look at the session cookie's attributes and whether failures are logged. When a review says "we've handled it," being able to ask "confirmed by which observation?" is this material's practical value.
If you have AI write code, or you promote AI adoption. For generated code, "it works" and "the defenses work" are separate claims. The lab's approach — define the defense and its check condition as a pair, and never pass a check whose prerequisites collapsed — is a yardstick for reviewing AI-generated authentication and authorization code. The automation side's fail-closed design (target allowlists, minimized evidence) is also a reference for deciding what scope to delegate to agents.
If you develop in a team. The six check conditions work as a shared language for security reviews. Being able to say "by LAB-01's condition, this screen is open to regular users" instead of "this somehow feels risky" moves the discussion forward even without a specialist in the room.
Summary
What I most want to convey with this lab is not the names of individual defenses, but the design of verification:
- Define the defense and its check condition as a pair: decide what to fix, and by which observation you will call it fixed, at the same time
- Never let ERROR become success: a check whose prerequisites are unconfirmed is "incomplete," not "passed" — and distinguish that in the exit code, too
- Do not widen what PASS means: do not speak of things you did not check (CSP strength, real browser behavior, CSRF as a whole) as if they had passed
- Make the checking tool fail-closed, too: target allowlists, minimized evidence, no exception strings recorded
Start by launching both modes and looking at how the results differ; then go deep on just one, with the LAB-01 worksheet (predict → observe → explain the cause → fix → re-verify). The repository is under the MIT license.
References
- Repository: https://github.com/moruku36/web-security-control-lab
- README — startup, the six checks, limitations
- SECURITY.md — the local-only scope
- docs/SECURITY_CONTROLS.md — each control's theory, detection logic, and OWASP mapping
- MDN: CSRF
- RFC 6265 §8.5 — cookies and port boundaries



Top comments (0)