Skip to content
routebase
API Authentication11 chapters

Chapter 10 of 11

API Authentication Testing: The Negative Cases

The negative cases worth writing, why 401 and 403 mean different things, and how two identities prove what a single-user suite structurally cannot.

API authentication testing is the part most likely to be assumed rather than done, and the reason is structural. Every other test in the suite authenticates successfully, so the happy path is covered a hundred times over and the interesting paths are covered zero times.

The negative cases, and what each one proves

Five cases, one line of configuration each, covering the ways authentication actually fails.

CaseExpectWhat it proves
No credential at all401The endpoint is genuinely protected
A malformed credential401Validation rejects garbage rather than crashing
An expired token401Expiry is enforced rather than decorative
A token for a different audience401The audience claim is checked
A valid credential without permission403Authorization is enforced per caller

The first one is the one people skip, and it is the one that catches an endpoint added to a router without its guard. A test asserting 401 on an unauthenticated call is one line and it prevents an incident category.

The expired-token case is easier than it sounds when you can mint your own tokens, because you set the expiry claim in the past and send it. Without that ability you wait for a real token to expire, and that is why the test rarely gets written.

401 and 403 are different answers

Returning the wrong one is a real defect, not a stylistic issue.

401 Unauthorized means the request was not authenticated. The caller is unknown, or their credential was invalid or expired. RFC 9110 requires a WWW-Authenticate header on a 401, naming the scheme that would be accepted. A client seeing 401 should try to authenticate again.

403 Forbidden means the caller is known and is not allowed to do this. Retrying with the same credential will not help, and a client seeing 403 should stop rather than loop.

An API that returns 401 for an authorization failure sends every well-behaved client into a re-authentication loop that cannot succeed. An API that returns 403 for a missing credential tells the client not to bother authenticating at all, and that is equally unhelpful.

Authorization needs a second identity

This is the part a normal suite cannot do, and it is where the most serious flaws live.

Every test in an ordinary suite authenticates as one identity. From inside that account, an endpoint that returns everything to everybody looks perfect, because everything it returns does belong to the caller.

The method is to hold two identities and cross them.

Object level (BOLA)
  As A   POST /orders              → 201, id = ord_a1
  As B   GET  /orders/ord_a1       → must be 404, never 200
 
Function level (BFLA)
  As member  GET /admin/users      → must be 403, never 200
 
Property level
  As member  GET /users/me         → response must not contain internalNotes
  As member  PATCH /users/me { "role": "admin" }
                                   → role must be unchanged afterwards

Three details decide whether these tests mean anything.

Use identities from different tenants for object-level checks. Two users in the same organisation may legitimately see the same data, so a 200 there is not evidence of anything.

Use a privilege gap for function-level checks. An owner and a plain member against an administrative endpoint is the shape.

Assert the effect, not only the status, for property-level writes. A 200 on an update that silently ignored the forbidden field is correct behaviour, and a 200 that applied it is a privilege escalation. Only a follow-up read tells you which happened.

404 or 403 for somebody else's object

Both are defensible and one is usually better.

A 403 says the object exists and you may not have it. That is honest, and it hands an attacker an existence oracle. Iterating identifiers and recording which return 403 rather than 404 maps your data without ever reading a record.

A 404 for both "does not exist" and "not yours" gives nothing away, and it is the better default for anything internet-facing.

The exception is inside a trusted boundary, such as an internal API where the callers are your own services, where a clear 403 saves real debugging time and the enumeration risk is acceptable.

What a scanner finds, and what only a test finds

Automated scanning covers a specific and useful slice of this.

Found by a scannerFound only by a test you wrote
An endpoint that returns 200 without a credentialA rule that the finance role may read but not export
One identity reading another's objectA workflow that can be driven out of order to skip a check
An administrative endpoint answering a normal userA field that may be set at creation and not on update
Sensitive fields in a response for a low-privilege callerA quota that resets in a way somebody can exploit

The left column is mechanical, so a scanner does it on every endpoint on every run and never gets bored. The right column needs somebody who knows what the API is for, and no scanner will ever produce it.

Run both. The scanner clears the mechanical findings so the human review has something useful left to do.

Test the token itself, not only the endpoint

Where you issue tokens, a few cases belong on the issuer side, not the consumer side.

Assert that a token is rejected when its signature does not verify and when the algorithm in the header is not the one you expect. Assert the same for a token whose audience names another service, and for one whose expiry has passed. The algorithm case is the one worth writing by hand, because RFC 8725 exists precisely because validators keep trusting the header.

In Routebase

The three modes of suite authentication are what make these tests possible. A suite set to No Auth sends no credential regardless of the environment, so the 401 case is a configuration choice, not a workaround. A case-level override sends a deliberately wrong credential for the malformed and expired cases. The JWT Bearer type mints a token with any payload you write, so an expired token or a wrong audience is a matter of editing the claims.

Security personas page with an ops-admin persona and a partner persona, both using bearer authentication.
A persona is a stored identity the scanners log in as, which is what makes a cross-tenant test possible.

For the multi-identity work, security personas hold the identities and the authorization scanners use them. The BOLA scanner creates a resource as the first persona and reads it back as the second, raising a critical finding when the second one succeeds. It skips endpoints with no matching create call instead of guessing. An enumeration fallback covers read-only endpoints with three guards, so a public catalogue behind auth is not reported as a breach. A second scanner calls administrative-looking endpoints as a plain member, and a third reads each response per identity looking for fields that identity should not see.

Personas must carry a real auth type, because a persona with no credentials tests nothing, and the guidance to use different tenants for object-level checks is written into the product documentation, not left as folklore.

Alongside the scanners, the business-logic cases are ordinary test cases and scenarios. A scenario that authenticates as one identity, creates something, then switches to a case configured with a second identity and asserts a refusal is exactly the test a scanner cannot write for you.

See Personas, Scanner Reference and Test Suites, and the wider method in API security testing.

Frequently asked questions

How do you test API authentication?

Write the negative cases, because the positive one is covered by every other test in the suite. Send no credential and assert 401, send a malformed one and assert 401, send an expired token and assert 401, and send a valid credential that lacks permission and assert 403. Each of those is one case, and together they prove the endpoint is protected rather than assumed to be.

What is the difference between 401 and 403?

A 401 means the request was not authenticated, so the caller is unknown or the credential was invalid, and it should carry a WWW-Authenticate header saying what would be accepted. A 403 means the caller is known and is not allowed to do this, so retrying with the same credential will not help. Returning 401 for an authorization failure sends clients into a pointless re-authentication loop.

How do you test for broken object level authorization?

Create a resource as one identity and then try to read it as a second identity from a different tenant. Anything other than a refusal is the flaw, and 404 is a better refusal than 403 because it does not confirm the object exists. A suite that authenticates as a single user cannot run this test at all, which is why the flaw survives so often.

Should an API return 404 or 403 for a resource that belongs to someone else?

A 404 is usually the better answer, because a 403 confirms the object exists and hands an attacker a free way to enumerate identifiers. Returning 404 for both a missing object and an object that is not yours gives nothing away. The exception is inside a trusted boundary where a clear 403 saves debugging time and the enumeration risk is acceptable.

Last reviewed by The Routebase Team.

Ready to ship on it?

Routebase is live. Design your API once — docs, mocks, tests, and monitoring all follow from the same source.

14-day Pro trial — no credit card required.