Map the API
Define request and response contracts
Specify allowed methods, parameters, bodies, content types, responses, and errors so unknown input and accidental data exposure are visible.
A contract is the written description of what your API accepts and what it returns. It gives clients and the server the same boundary. Without one, every field the server happens to accept is a field an attacker will try.
Open-ended objects are where accidental authority hides. Let’s close them.
Specify the request side
For each operation, describe the required fields, their limits, formats, and allowed values. Reject methods and content types you don’t support.
Here’s the request schema for creating an invoice:
{
"type": "object",
"required": ["customerId", "items"],
"additionalProperties": false,
"properties": {
"customerId": { "type": "string", "pattern": "^cus_[a-z0-9]{12}$" },
"items": { "type": "array", "minItems": 1, "maxItems": 100 },
"notes": { "type": "string", "maxLength": 500 }
}
}
Notice additionalProperties: false. That one line does real security work. Any field outside the contract gets rejected, instead of flowing silently into your database layer.
Publish the schema. OpenAPI works well for this. Clients, server validation, and contract tests then share one source of truth instead of three diverging opinions.
Don’t assume the rejection happens. Check it:
curl -i -X POST https://api.example.com/invoices \
-H "Content-Type: application/json" \
-d '{"customerId":"cus_ab12cd34ef56","items":[{"sku":"PLAN-PRO"}],"isPaid":true}'
# HTTP/1.1 400 Bad Request
# {"error":"unknown field: isPaid"}
If that request returns 201, your server accepted a field that lets a customer mark their own invoice as paid.
Select response fields deliberately
The response side leaks in the opposite direction. When code serializes the database row as-is, the invoice response carries internalNote along with everything else. Nobody decided to expose it. It just came along.
My advice is to build responses by hand, from an approved list of fields:
function invoiceResponse(row) {
return {
id: row.id,
customerId: row.customer_id,
total: row.total,
status: row.status,
}
}
A new column added by a migration never reaches clients until someone adds it here on purpose. That’s the behavior you want.
Test the whole response
Contract tests should look at the complete response, not only the required fields. Assert that the set of returned keys equals the approved set. Don’t just check that the required ones exist.
This catches the column that appears after a migration and was never approved for API clients. The test fails the day the column ships, not the day a customer notices it in your JSON.
Try this on one endpoint: write the request and response schemas for creating an invoice. Then send an unknown input field and add an internal database column. Prove the first is rejected and the second never shows up in the response.
Lesson completed