Security testing basics
Broken object access, auth bypasses, mass assignment and injection.
Security testing at the API level is mostly about checking that the API enforces its own rules. A user can see their own orders and not yours. A normal account can't call admin endpoints. A client can't set fields that only the server should set. None of this needs special hacking skill. It needs the same habits as the earlier lessons, applied to the question "what should this caller not be allowed to do?"
The OWASP API Security Top 10 (2023 edition) is the list most teams use as a checklist. You don't need to memorise it. Most of the items in it that a tester can check with ordinary requests are covered below.
Who can call which endpoint
Start with the easiest check: an endpoint meant for admins should refuse everyone else. This API has test tokens with different roles. user-token has the role user, and admin-token has admin and user. Send the user token to the admin endpoint:
/api/auth/roles/adminHTTP/1.1 403 Forbidden
{
"status": 403,
"error": "Forbidden",
"message": "This endpoint requires the 'admin' role.",
"roles": ["user"],
"scopes": ["read", "write"]
}A 403 is correct here. The token is valid, so this isn't a 401. It's a valid token without enough permission. With admin-token the same request returns 200. The same pattern applies to scopes: DELETE /api/auth/roles/resourcesign in to see needs the delete scope, and the user token gets a 403 that says so.
One request per role isn't enough in a real system. Write the rules down as a matrix, with every role across the top and every sensitive endpoint down the side, and fill in the status you expect. Then run all of it. The gaps usually show up in the corners: the export endpoint nobody remembered, the PUT that checks permissions while the PATCHon the same path doesn't, or the old /v1 route that still accepts a role the new one rejects.
| Endpoint | No token | user-token | admin-token |
|---|---|---|---|
GET /api/auth/roles/usersign in to see | 401 | 200 | 200 |
GET /api/auth/roles/adminsign in to see | 401 | 403 | 200 |
DELETE /api/auth/roles/resourcesign in to see | 401 | 403 | 200 |
That table is what the auth docs say should happen. Checking it is the exercise at the end of this lesson.
Whose record is it
The most common serious API bug has an awkward name: broken object level authorization, first on the OWASP list. It means the API checks that you are logged in but not that the record you asked for belongs to you.
Picture a shop API. You log in as Asha and fetch your order with GET /orders/1042. The response contains your address and the last four digits of your card. Now change the URL to /orders/1041. If you get someone else's order back, that is the bug. The server authenticated you correctly and then trusted the ID in the URL.
The method for testing it is always the same:
- Create two test users, A and B, each with some records of their own.
- Log in as A and note the IDs of A's records.
- Log in as B and request each of A's records by ID, with every method the endpoint supports. Reading is bad. Updating or deleting is worse.
- Expect a 404 or a 403. Many teams prefer 404, because a 403 confirms the record exists.
Look beyond the URL path. IDs also travel in query strings, in request bodies ("accountId": 7 inside a transfer request) and in nested routes like /users/7/invoices. Every one of them is a place where the server has to check ownership again.
This practice API doesn't have per-user records on its resource endpoints. Anyone can read any product or employee, on purpose. So you can't find this bug here. You will find it in real systems more often than you'd expect, and the only way to catch it is to test with two accounts.
Missing and broken credentials
The authentication lesson covered the negative tests: no token, a wrong token, an expired one, and a JWT whose payload was edited without re-signing. For a security review, run all of them against every protected endpoint, not a sample. An endpoint added last month may have been registered without the auth check. Here is what a correct rejection looks like on this API:
$ curl -i https://api.testingapis.com/api/auth/jwt/me
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
$ curl -i https://api.testingapis.com/api/auth/jwt/me -H "Authorization: Bearer abc.def.ghi"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token"Both are 401, and the WWW-Authenticateheader tells the client which scheme to use. The second one also says why the token was refused. What you don't want to see is a 500, which means the server crashed while reading the token, or a 200 with an empty body, which means the check didn't run at all.
Fields the client shouldn't set
Many APIs take the JSON body of a request and copy it onto a database record. If the copy isn't filtered, a client can set fields the form never showed: role, isAdmin, price, ownerId, balance. This is called mass assignment. The test is to send those fields and then check whether they were stored.
Try it on the employees resource. The body below sets an id (which the server should assign) and a rolefield that employees don't have:
curl -X POST https://api.testingapis.com/api/Employees \
-H "Content-Type: application/json" \
-d '{"id": 1, "firstName": "Test", "lastName": "Mass",
"email": "mass@example.com", "department": "Engineering",
"salary": 50000, "role": "admin"}'{
"message": "Employee created successfully",
"data": {
"id": 27,
"firstName": "Test",
"lastName": "Mass",
"email": "mass@example.com",
"department": "Engineering",
"salary": 50000,
"active": false,
...
}
}The server ignored the id of 1 and assigned 27, and it dropped role. That is safe. Silently ignoring unknown fields is one acceptable design. Rejecting them is another, and /api/validation/strictsign in to see shows it:
{
"type": "https://example.com/problems/unknown-fields",
"title": "Request body contains unknown fields.",
"status": 400,
"detail": "Allowed fields: name, email, quantity (names are case-sensitive).",
"instance": "/api/validation/strict",
"unknownFields": ["role"]
}Either is fine. The bug is the third option, where the field is stored. Don't trust the create response alone. Fetch the record again with a GET, because some APIs echo back what you sent while saving something different. Then try the same fields on PUT and PATCH, which are often written by a different developer on a different day. (Delete what you created afterwards.)
Limits on size and volume
An API that returns as much as it's asked for can be knocked over by one request. OWASP calls this unrestricted resource consumption. The tests are about whether limits exist and whether they hold.
On this API, list endpoints cap the page size. Ask for 1000 products and the response headers say x-per-page: 100. That is the cap working. Request bodies have limits too: the docs for /api/bodies/largesign in to see put the upload limit at 10 MB, and larger uploads get a 413. JSON nested deeper than the parser allows is rejected with a 400.
For your own APIs, check at least these:
- A page size far above the documented maximum
- A request body a few times larger than any real client would send
- Arrays with thousands of items where the UI allows ten, for example bulk endpoints
- Search terms that match everything, combined with the largest page size
- Anything that sends email or SMS, or costs money per call. These need a rate limit even when the rest of the API doesn't
Do these one request at a time. The point is to find out whether a limit exists, not to see how much load the server takes. That belongs in the performance lesson, on your own environment.
Input that looks like code
Testers send strings like ' OR 1=1 -- or <script>alert(1)</script>to check that the server treats input as data and never as part of a database query or a page. The correct result is boring: a search for that text finds nothing, and a name field containing it is stored and returned exactly as sent. What you're looking for is anything else. A database error in the response, a result count that changes, or a slow response to a string containing a sleep command are all worth reporting.
Try the SQL-looking search on this API with curl:
curl -i -G https://api.testingapis.com/api/Products --data-urlencode "q=' OR 1=1 --"You get a 403 Forbidden with Content-Type: text/html. That page doesn't come from the API, which only ever returns JSON. It comes from the firewall in front of it, which blocks requests that look like attacks. The API never saw the request.
This matters when you read test results. A firewall is a useful layer, but it isn't a fix, and it doesn't tell you whether the API itself handles the input safely. When a response doesn't match the format the API normally uses, work out which layer answered before you record a result. For injection testing, ask your team for an environment without the firewall, or with your test machine allowed through it. The edge cases endpoint /api/edge-cases/stringssign in to see returns a set of awkward strings (Unicode, right-to-left text, zero-width characters, injection-looking text) that are good test input for your own APIs.
What error messages give away
Send a product with a string where a number belongs:
curl -X POST https://api.testingapis.com/api/Products -H "Content-Type: application/json" -d '{"title":"x","price":"abc"}'{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"newProduct": ["The newProduct field is required."],
"$.price": ["The JSON value could not be converted to System.Decimal. Path: $.price | LineNumber: 0 | BytePositionInLine: 26."]
},
"traceId": "00-ffeb0f935ad44537bcfedcc1860e81b5-cb257520b1d5e8bc-00"
}The status is right, and the message is useful to a developer. It also reveals that the server is .NET (System.Decimal) and the name of a parameter in the code (newProduct). On a practice API that doesn't matter. On a production API it's a small finding, worth a low-priority ticket. The findings that matter more are stack traces, SQL statements, file paths, internal hostnames and library versions in error bodies. Trigger as many different errors as you can (wrong types, huge numbers, missing bodies, invalid IDs) and read every body.
Response headers
A few response headers are worth checking on any API. This one gets some right and skips others, which is a reasonable choice for a public practice API, so read the right column as a description, not a verdict.
| What to check | Why | This API |
|---|---|---|
| Plain HTTP redirects or is refused | Credentials must never travel unencrypted | 301 to https |
Strict-Transport-Security | Tells browsers to use HTTPS only | Not sent |
Cache-Control: no-store | Keeps personal data out of shared caches | Not sent on most responses |
X-Content-Type-Options: nosniff | Stops browsers guessing a content type | Not sent |
Access-Control-Allow-Origin | Which websites may read responses | *, without credentials |
| Headers naming the software | Saves an attacker a step | x-powered-by and x-render-origin-server: Kestrel |
For CORS, the combination to report is an API that reflects any Origin back and also sends Access-Control-Allow-Credentials: true. That lets any website make logged-in requests on behalf of a visitor. A wildcard without credentials, as here, is fine for public data.
Cookies and CSRF
If an API uses cookies for login, a browser attaches them to every request to that site, including requests started by another site. Cross-site request forgery (CSRF) uses that. The usual defences are SameSite cookie attributes (covered in the cookies lesson) and a token that the client has to send back in a header. This API has a double-submit example: GET /api/auth/csrf/tokensign in to see sets the token as a cookie, and POST /api/auth/csrf/submitsign in to see only succeeds when the X-CSRF-Token header matches it. The tests are to submit without the header, with a different value, and with the header but no cookie. All three should fail. APIs that use bearer tokens in the Authorizationheader aren't exposed to CSRF in the same way, because browsers don't add that header on their own.
Tools
Everything above works with curl and a test runner, and writing the checks as automated tests means they run on every build. For exploratory security work, most people use an intercepting proxy. OWASP ZAP is free and open source, and Burp Suite has a free community edition. Both sit between your client and the API, so you can pause a request, change it and send it again. Both also have automated scanners. Run those only against environments you own, and read their findings critically: they report many possibilities, and some are false positives.
What to write down
Security findings need the same bug report as any other (request, expected, actual), plus two extra lines: what an attacker could do with it, and who could do it (anyone, any logged-in user, only an admin). That second line is usually what decides the priority. Report security bugs through whatever private channel your team uses, not a public issue tracker.
Exercise
Check the role matrix from the start of this lesson. Send each of the three requests with no token, with user-token, with admin-token, and then with readonly-token, which the table doesn't cover. Before each request, write down the status you expect.
Then pick one write endpoint, such as POST /api/Bookssign in to see, and test it for mass assignment: send an id, a createdAtand a field that doesn't exist, fetch the record again, and note what was stored. Delete the record when you're done.