This project is a small Express.js + Passport JWT + SQLite lab for learning:
- IDOR: Insecure Direct Object Reference
- BAC: Broken Access Control
- how security regression tests catch these bugs
The same app can run in two modes:
VULNERABLE=true: intentionally insecure for trainingVULNERABLE=false: patched with ownership checks
Students should be able to:
- Run the vulnerable API.
- Create two users.
- Create a todo for one user.
- Access that todo from the other user by changing the ID in the URL.
- Switch to the patched mode.
- Repeat the same requests and observe that the API now blocks them.
- Run the security regression test and see that it fails on the vulnerable mode and passes on the patched mode.
todo-api/
├── server.js
├── Dockerfile
├── docker-compose.yml
├── database.sqlite
├── package.json
├── config/
│ └── passport.js
├── models/
│ ├── Todo.js
│ └── User.js
├── routes/
│ ├── auth.js
│ ├── todos.vulnerable.js
│ └── todos.patched.js
└── tests/
└── todos.unit.security.test.js
Install these first:
- Node.js 18+
- npm 9+
- Git
- Docker Desktop or Docker Engine if you want to use containers
git clone <your-repo-url>
cd todo-apiIf you already have the folder, open a terminal in the project root.
npm installYou do not need separate branches to switch between insecure and secure behavior.
Use the VULNERABLE environment variable:
VULNERABLE=truerunsroutes/todos.vulnerable.jsVULNERABLE=falserunsroutes/todos.patched.js
Important:
- if you do not set
VULNERABLE, the app starts in vulnerable mode - the default local port is
3000
VULNERABLE=true npm startExpected startup output:
Running VULNERABLE version (IDOR/BAC enabled)
Server running on port 3000
Base URL:
http://localhost:3000
Stop the running server with Ctrl+C, then start:
VULNERABLE=false npm startExpected startup output:
Running PATCHED version (Secure)
Server running on port 3000
Base URL:
http://localhost:3000
If port 3000 is already in use:
PORT=4000 VULNERABLE=true npm startThen replace 3000 with 4000 in the examples below.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/register |
Register a user |
| POST | /api/auth/login |
Log in and get a JWT |
All todo endpoints require:
Authorization: Bearer <token>
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/todos |
List todos for the logged-in user |
| GET | /api/todos/:id |
Get one todo |
| POST | /api/todos |
Create a todo |
| PUT | /api/todos/:id |
Update a todo |
| DELETE | /api/todos/:id |
Delete a todo |
This walkthrough shows the vulnerability first, then the fix.
VULNERABLE=true npm startcurl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret123"}'Example response:
{
"token": "<alice-token>",
"user": {
"id": "1712345678901",
"username": "alice"
}
}Save the token value as ALICE_TOKEN.
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"bob","password":"secret123"}'Save the token value as BOB_TOKEN.
curl -X POST http://localhost:3000/api/todos \
-H "Authorization: Bearer <BOB_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title":"Bob private todo"}'Example response:
{
"id": "1",
"userId": "1712345678902",
"title": "Bob private todo",
"completed": false
}Save the todo ID as BOB_TODO_ID.
curl http://localhost:3000/api/todos/<BOB_TODO_ID> \
-H "Authorization: Bearer <BOB_TOKEN>"curl http://localhost:3000/api/todos/<BOB_TODO_ID> \
-H "Authorization: Bearer <ALICE_TOKEN>"In vulnerable mode, this incorrectly succeeds.
Expected vulnerable result:
HTTP 200 OK
curl -X PUT http://localhost:3000/api/todos/<BOB_TODO_ID> \
-H "Authorization: Bearer <ALICE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title":"Alice changed Bobs todo","completed":true}'In vulnerable mode, this incorrectly succeeds.
Expected vulnerable result:
HTTP 200 OK
curl -X DELETE http://localhost:3000/api/todos/<BOB_TODO_ID> \
-H "Authorization: Bearer <ALICE_TOKEN>"In vulnerable mode, this incorrectly succeeds.
Expected vulnerable result:
HTTP 200 OK
This is the IDOR and broken access control issue the lab is meant to teach.
Press Ctrl+C in the terminal.
VULNERABLE=false npm startUse different usernames because the in-memory user store resets when the server restarts.
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice2","password":"secret123"}'curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"bob2","password":"secret123"}'Save the new tokens as ALICE2_TOKEN and BOB2_TOKEN.
curl -X POST http://localhost:3000/api/todos \
-H "Authorization: Bearer <BOB2_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title":"Bob2 private todo"}'Save the returned ID as BOB2_TODO_ID.
Read attempt:
curl http://localhost:3000/api/todos/<BOB2_TODO_ID> \
-H "Authorization: Bearer <ALICE2_TOKEN>"Update attempt:
curl -X PUT http://localhost:3000/api/todos/<BOB2_TODO_ID> \
-H "Authorization: Bearer <ALICE2_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title":"Attempted attack","completed":true}'Delete attempt:
curl -X DELETE http://localhost:3000/api/todos/<BOB2_TODO_ID> \
-H "Authorization: Bearer <ALICE2_TOKEN>"In patched mode, each request should be rejected.
Expected patched result:
HTTP 403 Forbidden
The regression suite is in tests/todos.unit.security.test.js.
It enforces this secure contract:
- a user must not read another user's todo by ID
- a user must not update another user's todo by ID
- a user must not delete another user's todo by ID
VULNERABLE=true npm run test:securityExpected result:
FAIL
This failure is intentional. It proves the vulnerability exists.
VULNERABLE=false npm run test:securityExpected result:
PASS
This proves the fix satisfies the security contract.
VULNERABLE=false npm testThis runs the same Jest suite and should pass in patched mode.
VULNERABLE=false npm run test:coverageIf you run coverage in vulnerable mode, expect the suite to fail for the same reason.
docker-compose up --buildThis starts:
- vulnerable API at
http://localhost:3001 - patched API at
http://localhost:3002
You can repeat the same curl walkthrough above by changing the port:
- use
3001for vulnerable mode - use
3002for patched mode
Vulnerable-mode test run:
docker-compose run todo-testsExpected result:
FAIL
This is intentional because the container runs with VULNERABLE=true.
Patched-mode test run:
docker-compose run -e VULNERABLE=false todo-testsExpected result:
PASS
docker-compose downIn VULNERABLE=true mode:
GET /api/todos/:iddoes not verify ownershipPUT /api/todos/:iddoes not verify ownershipDELETE /api/todos/:iddoes not verify ownership
That means one authenticated user can act on another user's todo by changing the object ID.
In VULNERABLE=false mode, the API checks that the todo belongs to the authenticated user before reading, updating, or deleting it.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP server port |
VULNERABLE |
true |
true runs insecure routes, false runs patched routes |
- Users are stored in memory, so restarting the server resets registered users.
- Todos are stored in SQLite, so todo records can remain in
database.sqlitebetween runs. - If test runs behave strangely, stop any running server before executing Jest.
- If usernames already exist, register new usernames such as
alice3andbob3.
Run the app on another port:
PORT=4000 VULNERABLE=false npm startThat is expected for the regression suite. The tests are supposed to fail when the app is insecure.
Make sure Docker is running, then retry:
docker-compose up --buildISC - Educational Use Only