How to test API authentication
Send the same request four times: with no credential, with a malformed one, with a valid one, and with a valid one aimed at another account's resource. A protected endpoint answers 401 to the first two, 200 to the third, and 401 or 403 to the fourth.
Why check this
An endpoint that forgot its authentication filter returns 200 to everyone and passes every functional test, because the tests all run logged in. Run this check when a route is added, when a framework or gateway is upgraded, and on staging sign-off for anything behind a login. The failure it prevents is a reporting endpoint that serves another customer's records to an unauthenticated request.
Prerequisites
- curl 7.0 or later. See the curl manual.
- A credential issued for the test environment. Never send a production token to a host you do not own, and never paste one into a public checker.
- The list of routes that are supposed to require authentication, including the ones added in this release.
- RFC 9110 section 15.5.2 for what a
401owes the client.
Steps
- Step 1.
Send the request with no credential at all.
curl -s -D - -o /dev/null 'https://httpbin.org/basic-auth/user/passwd' | grep -i -E '^(HTTP|www-authenticate)'HTTP/2 401 www-authenticate: Basic realm="Fake Realm"The
www-authenticateheader names the scheme the endpoint expects. It tells you which of the following steps applies. - Step 2.
Send the credential that is supposed to work.
curl -s -u user:passwd 'https://httpbin.org/basic-auth/user/passwd' -w '\nHTTP %{http_code}\n'{ "authenticated": true, "user": "user" } HTTP 200Without this step the rest proves nothing. A route that is broken for every caller also rejects every negative case.
- Step 3.
Send the same user with the wrong secret.
curl -s -u user:wrong 'https://httpbin.org/basic-auth/user/passwd' -D - -o /dev/null | grep -i -E '^(HTTP|www-authenticate)'HTTP/2 401 www-authenticate: Basic realm="Fake Realm"The response is byte for byte the response from step 1. That is the shape you want. A different status or a longer body for a real user name hands an attacker a way to enumerate accounts.
- Step 4.
Send a working credential at a resource it does not own.
curl -s -o /dev/null -w '%{http_code}\n' -u user:passwd 'https://httpbin.org/basic-auth/admin/adminpw'401This is the case that finds broken object level authorization. The credential is valid, the token parses, and the resource belongs to someone else.
- Step 5.
Send a header that carries no scheme.
curl -s -D - -o /dev/null -H 'Authorization: notatoken' 'https://httpbin.org/bearer' | grep -i -E '^(HTTP|www-authenticate)'HTTP/2 401 www-authenticate: BearerA malformed header must be rejected, not ignored. An endpoint that answers
200here treats a garbage header as an absent one and falls back to an anonymous session. - Step 6.
Read the error body a real API returns for a bad token.
curl -s -D - -H 'Authorization: Bearer not-a-real-token' 'https://api.github.com/user' | grep -i -E '^(HTTP|www-authenticate)|message|status'HTTP/2 401 "message": "Bad credentials", "status": "401"The same endpoint with no header at all answers
"message": "Requires authentication". Two different texts for two different faults, and nowww-authenticateheader on either. - Step 7.
Put a key in the query string and read where it lands.
curl -s 'https://httpbin.org/get?api_key=EXAMPLE-NOT-A-REAL-KEY' | grep -E '"(api_key|url)"'"api_key": "EXAMPLE-NOT-A-REAL-KEY", "url": "https://httpbin.org/get?api_key=EXAMPLE-NOT-A-REAL-KEY"The server echoed the full URL because the key is part of it. That URL is what an access log writes, what a referrer header leaks, and what a browser keeps in history. A key belongs in a header.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 401 with www-authenticate | The endpoint refuses and names its scheme | Correct. Move to step 2. |
| 200 on step 1 | The route has no authentication filter | Stop and file it. Nothing below matters. |
| 401 on step 1, 200 on step 5 | A malformed header is treated as no header | File it. The parser accepts input it cannot understand. |
| 200 on step 4 | Authentication passes, authorization does not exist | File it as broken object level authorization, not as a login bug. |
| 403 on step 4 | The caller is known and not permitted | Correct for a resource whose existence is public. |
| 404 on step 4 | The caller is known and the resource is hidden | Correct for a resource whose existence is itself private. |
| A different body for a real user than for an unknown one | The endpoint confirms which accounts exist | File it. Account enumeration starts here. |
Common mistakes
Thresholds
401 responses from api.github.com that carried a www-authenticate header, measured on 2026-09-11. The specification requires the header on every 401. httpbin.org sent it on both of its 401 responses, so the client behaviour differs between two APIs that are both returning the same status.
What to check next
- How to check JWT expiration: the expired credential case, once the token is a JWT you can read.
- How to decode JWT: read the claims before deciding whether the wrong resource test used the right account.
- How to check HTTP status code: the status is the assertion in every step above.
- How to test API error responses: the body shape that step 6 samples, applied to the rest of the API.
- How to check rate limit headers: failed logins are the first thing a quota is supposed to slow down.
- Decode a JWT: paste a test token, never a production one.
FAQ
How to test an API with a bearer token?
Send it as a header: -H 'Authorization: Bearer <token>'. Then repeat the request with the token truncated, with the scheme removed, and with an expired token. An endpoint that answers 200 to any of those accepts credentials it cannot verify.
How to test an API key?
The same four cases apply, with the key in whichever header the API documents. Add one case the bearer flow does not need: send the key in the query string. If it works there too, the key will end up in logs.
How to test API security beyond authentication?
Authentication answers who is calling. Run the authorization cases next, one account reaching another account's records, then input validation, rate limiting and CORS. Step 4 above is the bridge between the two.
Is 401 or 403 correct for a valid token on a forbidden resource?
403 when the caller is authenticated and not permitted. 401 when the credential itself is rejected. 404 is also correct when revealing that the resource exists would itself leak information.
Can I test this without a valid credential?
Partly. Steps 1, 5 and 6 need no credential and already find a missing filter or a lenient parser. Steps 2 and 4 need one, and without step 2 a green run means nothing.
Verified
Verified by Maks Vernycurl 8.21.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny