Testing authorization: roles, scopes and CSRF
A test matrix for who may do what, and the cookie attack tokens were meant to stop.
Authentication answers "who are you?". Authorization answers "are you allowed to do this?". The earlier lessons were about the first question. This one is about the second, which is where the worst API bugs tend to be: the OWASP API Security Top 10 puts broken object-level authorization first and broken function-level authorization fifth. Authentication can be perfect and the API still hand any logged-in user everyone else's data.
401 and 403
The two status codes map onto the two questions. 401 Unauthorized, despite its name, means the server doesn't know who you are: no credentials, or credentials that don't check out. It comes with a WWW-Authenticateheader. 403 Forbidden means the server knows exactly who you are, and the answer is no. Getting them backwards breaks clients: a client that sees 401 tries to log in again, which can't fix a missing permission, and a client that sees 403 for an expired token never refreshes it.
The roles endpoints on this API use three fixed tokens with different rights:
| Token | Roles | Scopes |
|---|---|---|
admin-token | admin, user | read, write, delete |
user-token | user | read, write |
readonly-token | none | read |
/api/auth/roles/adminA role describes the user: what kind of account this is. A scope describes the token: what this particular token was allowed to do when it was issued. The same admin can hold a token with only the read scope, for example one given to a reporting tool. A complete check usually needs both, and a common bug is checking only one of them.
The authorization matrix
The reliable way to test authorization is a table: every kind of caller down the side, every endpoint across the top, and the expected status in each cell. Then you test every cell. It sounds tedious, and it is, which is why it belongs in code:
// matrix.test.mjs: every token against every endpoint, with the expected status
import { test } from "node:test";
import assert from "node:assert/strict";
const BASE = "https://api.testingapis.com";
const endpoints = [
{ method: "GET", path: "/api/auth/roles/admin" },
{ method: "GET", path: "/api/auth/roles/user" },
{ method: "DELETE", path: "/api/auth/roles/resource" },
];
// One row per caller, one column per endpoint, in the order above
const matrix = {
"admin-token": [200, 200, 200],
"user-token": [403, 200, 403],
"readonly-token": [403, 403, 403],
"not-a-token": [401, 401, 401],
"(none)": [401, 401, 401],
};
for (const [token, expected] of Object.entries(matrix)) {
endpoints.forEach(({ method, path }, i) => {
test(`${token} ${method} ${path} -> ${expected[i]}`, async () => {
const headers = token === "(none)" ? {} : { Authorization: `Bearer ${token}` };
const res = await fetch(BASE + path, { method, headers });
assert.equal(res.status, expected[i]);
});
});
}$ node --test matrix.test.mjs
✔ admin-token GET /api/auth/roles/admin -> 200 (175.9482ms)
✔ admin-token GET /api/auth/roles/user -> 200 (74.3448ms)
✔ admin-token DELETE /api/auth/roles/resource -> 200 (39.5801ms)
✔ user-token GET /api/auth/roles/admin -> 403 (38.6979ms)
✔ user-token GET /api/auth/roles/user -> 200 (38.913ms)
✔ user-token DELETE /api/auth/roles/resource -> 403 (39.6974ms)
✔ readonly-token GET /api/auth/roles/admin -> 403 (19.7902ms)
...
ℹ tests 15
ℹ pass 15
ℹ fail 0All fifteen cells hold on this API. The value of the matrix is less in the first run than in every run after it. When a developer adds a new endpoint and forgets the role check, the new column has no row that expects a 403, and the gap is visible in review. When someone changes a shared permission, the cells that move show exactly who gained or lost access.
The 403s here also say what was missing:
{"status":403,"error":"Forbidden","message":"This endpoint requires the 'delete' scope.","roles":["user"],"scopes":["read","write"]}That's helpful for a test API and a judgement call for a real one. Telling a caller which scope they lack helps legitimate developers; listing every role and scope the token has also tells an attacker what a stolen token is worth. Decide which matters more for the API you test.
Function-level checks
The admin column above is a function-level check: may this kind of user call this kind of operation? The bugs here come from endpoints protected in the user interface but not in the API. An admin button hidden from normal users means nothing if the request behind it works for them. For each admin-only feature, capture the request an admin's browser sends and replay it with a normal user's token.
Object-level checks
The matrix tests whether a user may call an endpoint at all. Object-level authorization asks whether they may touch this particular record. A user may read orders, but only their own. The classic bug: the API checks that the caller is logged in, takes the order ID from the URL, and returns that order, whoever owns it.
The test needs two accounts:
- As user A, create a record and note its ID.
- As user B, try to read, update and delete it by that ID.
- Expect every attempt to fail, with 403 or with 404. Many APIs prefer 404, so B can't even learn the record exists.
- Check that A's record is unchanged afterwards. A 403 that still applied the update is a real finding.
- Repeat for IDs inside request bodies and nested paths, such as
/users/A/orders/123called by B.
Sequential numeric IDs make this easy to exploit, because B can simply try every number. Random IDs make it harder to find records but don't replace the check. The security testing lesson in the REST tutorial goes further into this and the other common API attacks.
CSRF: when the browser authenticates for you
Cookie sessions, from lesson 1, have a weakness bearer tokens don't: the browser attaches the cookie to every request to that site, including requests started by other sites. A page on an attacker's site can submit a form to your bank, and the bank sees your session cookie. That's cross-site request forgery. SameSite=Lax cookies block most of it in modern browsers, and a CSRF token is the traditional second layer.
Double-submit cookies
In the double-submit pattern, the server sets a random token in a cookie, and the page's script copies it into a header on every request that changes something. An attacker's site can make the browser send the cookie, but can't read it to put it in the header. The server checks that the two match:
$ curl -i https://api.testingapis.com/api/auth/csrf/token
Set-Cookie: XSRF-TOKEN=csrf_3074c20dfa4c6de399ef10e5a88034c027f06d33.10856a76d7df693afdaaff4026d6cd77f8b6b56dc3f8539cfaf3c769436e7ff4; path=/; samesite=lax
{"csrfToken":"csrf_3074c20dfa4c6de399ef10e5a88034c027f06d33.10856a76d7df693afdaaff4026d6cd77f8b6b56dc3f8539cfaf3c769436e7ff4","message":"Cookie XSRF-TOKEN set. ..."}
$ curl -X POST https://api.testingapis.com/api/auth/csrf/submit \
-H "Content-Type: application/json" \
-H "Cookie: XSRF-TOKEN=csrf_3074c20dfa4c6de399ef10e5a88034c027f06d33.10856a76d7df693afdaaff4026d6cd77f8b6b56dc3f8539cfaf3c769436e7ff4" \
-H "X-CSRF-Token: csrf_3074c20dfa4c6de399ef10e5a88034c027f06d33.10856a76d7df693afdaaff4026d6cd77f8b6b56dc3f8539cfaf3c769436e7ff4" \
-d '{"comment": "hello"}'
{"message":"CSRF check passed. Form submitted.","submittedAt":"2026-10-11T11:01:15Z"}| Request | Response |
|---|---|
| Cookie and header match | 200 |
| Header only | 403 Missing XSRF-TOKEN cookie. |
| Cookie only | 403 Missing X-CSRF-Token header. |
| Both present, different values | 403 X-CSRF-Token header does not match the XSRF-TOKEN cookie. |
Both set to attacker, a value the server never issued | 403 CSRF token was not issued by this server. |
The last row is the one plain double-submit gets wrong:
$ curl -X POST https://api.testingapis.com/api/auth/csrf/submit \
-H "Content-Type: application/json" \
-H "Cookie: XSRF-TOKEN=attacker" \
-H "X-CSRF-Token: attacker" \
-d '{"comment": "hello"}'
{"status":403,"error":"Forbidden","message":"CSRF token was not issued by this server. Call GET /api/auth/csrf/token for a signed one."}A plain double-submit check only compares the cookie with the header. An attacker who can plant a cookie for the site, through a vulnerable subdomain or a response sent over plain HTTP, can choose a matching pair and pass it. This server uses the signed variant instead: each token is a random value plus an HMAC of it, made with a key only the server holds, as in the csrf_3074c2…token above. A made-up pair has no valid signature, so it fails even though cookie and header agree. The OWASP CSRF guidance goes one step further and ties the signature to the user's session, so a token from one user's session can't be used in another's. Naming the cookie with the __Host- prefix also stops subdomains from setting it. When you test a CSRF defence, send a matching pair of your own invention and see whether it passes.
Authorization test checklist
- No credentials and bad credentials give 401 with
WWW-Authenticate; valid credentials without permission give 403. - An authorization matrix covers every endpoint and every role, and runs in CI.
- Admin-only operations fail for normal users when the request is replayed directly, not just hidden in the UI.
- Every endpoint that takes an object ID refuses objects that belong to someone else.
- Scopes are enforced as well as roles: an admin's read-only token can't delete.
- Permission changes take effect as promised: at once, or when the current token expires.
- Cookie-authenticated requests that change data need a CSRF token the server issued, not just one that matches.
Exercise
Extend the matrix test with a fourth endpoint, GET /api/auth/bearersign in to see, which accepts only the static token apibee-token-123. Fill in the expected status for every row before running it. Then add a row for apibee-token-123 itself against the three roles endpoints. Which cells surprised you, and is each surprise a bug, or a gap in what the docs told you?