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

Steps

  1. 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-authenticate header names the scheme the endpoint expects. It tells you which of the following steps applies.

  2. 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 200

    Without this step the rest proves nothing. A route that is broken for every caller also rejects every negative case.

  3. 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.

  4. 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'
    
    401

    This is the case that finds broken object level authorization. The credential is valid, the token parses, and the resource belongs to someone else.

  5. 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: Bearer

    A malformed header must be rejected, not ignored. An endpoint that answers 200 here treats a garbage header as an absent one and falls back to an anonymous session.

  6. 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 no www-authenticate header on either.

  7. 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

Sign: Every negative case returns 401, and the suite is declared green.Cause: The positive case was never run against the same build. A route that is broken for everyone answers 401 to a valid credential too, so the negative cases pass for the wrong reason. Step 2 exists to rule that out.
Sign: Basic auth is treated as encryption because the header looks encoded.Cause: curl -u user:passwd sends Authorization: Basic dXNlcjpwYXNzd2Q=, and base64 -d turns that string straight back into user:passwd. The encoding hides nothing. Only TLS protects it, which is why the same test over http is a finding on its own.
Sign: A token works in the test suite and fails in production with the same value.Cause: The suite reads the credential from an environment variable that the CI job sets and the production client does not, or the gateway strips the Authorization header on a redirect. curl drops that header across hosts unless told otherwise, which reproduces the second case.
Sign: The API accepts the key in the query string and in the header, and only the header is tested.Cause: Both paths reach the same handler, so a client that uses the query string writes the key into every log line and every referrer. Test both, and treat the query string form as a defect even when it works.

Thresholds

0 of 2

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.

Source: RFC 9110 section 15.5.2

What to check next

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.

intermediate8 minpublished updated Maks Verny