What You'll Build
In this lab you will take an insecure REST API and harden it against the Module 4 risks: you will add object level authorization, an input schema that stops mass assignment, an output projection that stops data exposure, and rate limiting on a sensitive endpoint. You will finish with tests that prove each control works.
The aim is to convert the checklist into muscle memory by applying every control to one small, coherent API. Rather than studying the risks separately, you will see how object authorization, schema validation, output projection, and rate limiting fit together on the same endpoints, which is exactly how they must combine in real services.
Prerequisites
- Node.js 18+ and npm installed, plus a terminal for running commands.
- Completion of the Module 4 readings on the API Top 10, rate limiting, and mass assignment, whose controls you will apply directly.
- Basic familiarity with Express routes and JSON request and response bodies.
- An understanding that these techniques apply to this local lab or APIs you are explicitly authorised to test only.
Setup & Project Structure
Create a small Express API with a users resource that is deliberately insecure: it authorises only by login, binds the whole request body on update, returns raw user objects, and has no rate limiting. Seed a couple of users so you can attempt cross-user access. Run it locally and keep an HTTP client handy to send crafted requests throughout.
# Scaffold the insecure API
mkdir secure-rest-lab && cd secure-rest-lab
npm init -y
npm install express express-rate-limit zod
# In app.js: GET/PATCH /api/users/:id with NO object authorization,
# whole-body binding on PATCH, raw object responses, no rate limits.
node app.js # serves on http://localhost:3000Step 1 — Foundation: Confirm the Weaknesses
Before fixing anything, demonstrate each flaw against the running API. Log in as one user and read another user's record by changing the id, confirming broken object level authorization and excessive data exposure in one call. Then send a profile update that includes an isAdmin field to confirm mass assignment. Record each request and the response that proves the weakness.
# Confirm BOLA + excessive exposure: read another user's record
curl -s http://localhost:3000/api/users/2 -H "Authorization: Bearer <user1-token>"
# Returns user 2's full record incl. passwordHash — two flaws at once.
# Confirm mass assignment: escalate via an unexpected field
curl -s -X PATCH http://localhost:3000/api/users/1 \
-H "Authorization: Bearer <user1-token>" -H "Content-Type: application/json" \
-d '{"displayName":"Alice","isAdmin":true}'
# The isAdmin field is bound and set — privilege escalation.Step 2 — Core Logic: Add Authorization and Schema Validation
Now apply the two central controls. Scope every object access to the authenticated caller so reading another user's record returns 404, closing broken object level authorization. Add a strict input schema on update that accepts only the fields a user may change and rejects unknown fields, closing mass assignment. Re-run your Step 1 requests to see both attacks now fail.
// Object level authorization: scope to the caller
app.get('/api/users/:id', requireAuth, async (req, res) => {
if (String(req.user.id) !== req.params.id) return res.status(404).end();
const user = await db.users.findById(req.params.id);
res.json(user); // output projection added in Step 3
});
// Strict input schema stops mass assignment
const UpdateUser = z.object({
displayName: z.string().max(80),
bio: z.string().max(500).optional(),
}).strict(); // unknown fields (isAdmin) rejected
app.patch('/api/users/:id', requireAuth, async (req, res) => {
if (String(req.user.id) !== req.params.id) return res.status(404).end();
const data = UpdateUser.parse(req.body); // throws on isAdmin
res.json(await db.users.update(req.params.id, data));
});Step 3 — Integration & Enhancement: Output Projection and Rate Limits
Next, close the remaining two gaps. Add an output projection so responses carry only safe fields, ensuring the password hash and internal flags never leave the server even on the user's own record. Then add a strict rate limiter to the login endpoint, keyed to the account, so credentials cannot be brute-forced. Verify the projection hides sensitive fields and the limiter returns 429 after the threshold.
// Output projection: never emit sensitive fields
function publicUser(u) {
return { id: u.id, displayName: u.displayName, bio: u.bio }; // safe set
}
// Apply publicUser() to every user response above.
// Strict, account-keyed rate limit on login
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, max: 5,
keyGenerator: req => req.body.email || req.ip,
standardHeaders: true,
});
app.post('/login', loginLimiter, handleLogin); // 429 after 5 triesStep 4 — Testing & Verification: Prove Every Control
Finally, lock all four controls in with automated tests so a future change cannot silently regress them. Assert that reading another user's record returns 404, that an update containing isAdmin is rejected, that responses never contain the password hash, and that the sixth rapid login attempt returns 429. A green suite is your evidence that the API meets the Module 4 checklist.
// verify.test.js — one test per control
test('BOLA closed: other user record is 404', async () => {
expect((await get('/api/users/2', user1)).status).toBe(404);
});
test('mass assignment rejected: isAdmin refused', async () => {
const r = await patch('/api/users/1', user1, { displayName:'A', isAdmin:true });
expect(r.status).toBe(400);
});
test('no data exposure: response omits passwordHash', async () => {
const r = await get('/api/users/1', user1);
expect(r.body).not.toHaveProperty('passwordHash');
});
test('rate limit: 6th login returns 429', async () => {
for (let i = 0; i < 5; i++) await post('/login', { email, pass: 'x' });
expect((await post('/login', { email, pass: 'x' })).status).toBe(429);
});Warning: Run this lab only against your own local API. The crafted requests here are safe on your machine but constitute unauthorised access and testing if aimed at any system you do not own or have explicit written permission to test. Keep the server on localhost and never point these requests at a live third-party service.
Extension Challenge: Extend the lab three ways. Add function level authorization by introducing an admin-only endpoint and proving a normal user gets 403, covering the second-biggest API risk. Move the rate-limit counter into Redis and confirm the limit holds when you run two server instances. Finally, add a response schema (not just a request schema) so a newly added internal field cannot leak, demonstrating the output-boundary discipline from Lesson 23.
- Real API hardening combines controls on the same endpoints: object authorization, input schema, output projection, and rate limiting working together.
- Reading another user's record by changing the id demonstrates broken object level authorization and excessive data exposure in a single request.
- A strict input schema that rejects unknown fields closes mass assignment, so an added isAdmin field is refused rather than bound.
- Scoping every object access to the authenticated caller and returning 404 for others' records closes broken object level authorization.
- An output projection ensures sensitive fields like the password hash never leave the server, even on the user's own record.
- Automated tests — one per control — prove the API meets the checklist and guard against a future change silently regressing any control.