TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Authentication and authorization

Basic auth, API keys, bearer tokens and JWTs, and the difference between 401 and 403.

Most real APIs don't answer everyone. Before the server does anything useful, it wants to know who is asking, and then whether that person is allowed to do what they asked. Those are two separate questions, and a lot of security bugs come from an API that answers the first one and forgets the second.

Authentication is the first question: who are you. The client proves its identity with a password, a key or a token. Authorization is the second: now that I know who you are, may you do this. A logged-in customer is authenticated. That doesn't mean they may delete another customer's order.

401 and 403

HTTP has a status code for each question, and the names are misleading. 401 Unauthorizedactually means unauthenticated: the server doesn't know who you are, because you sent no credentials or the ones you sent are wrong. 403 Forbidden means the server knows exactly who you are and the answer is still no.

A 401 should come with a WWW-Authenticate header that tells the client how to authenticate. Send a request without credentials to the Basic auth endpoint:

Terminal
$ curl -i https://api.testingapis.com/api/auth/basic

HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
www-authenticate: Basic realm="apibee", charset="UTF-8"

{"status":401,"error":"Unauthorized","message":"Missing or malformed Basic Authorization header."}
This example uses endpoints for signed-in members.Sign inCreate a free account

The header says: use Basic authentication, for the protection space called apibee. Browsers read this header and show their built-in login box. API clients mostly ignore it, but it is part of the spec, and its absence on a 401 is a small bug worth reporting.

When you test an endpoint, the distinction matters because the two codes lead to different client behaviour. On a 401 a client should ask the user to log in again or refresh its token. On a 403 logging in again won't help, so the client should show a "you don't have access" message. An API that returns 401 for a permissions problem sends users into a login loop.

Basic auth

Basic auth is the oldest scheme and the simplest. The client joins the username and password with a colon, encodes the result as Base64 and sends it in the Authorization header. With curl, the -u option does this for you, and -v shows the header it built:

Terminal
$ curl -v -u apibee:password123 https://api.testingapis.com/api/auth/basic

> GET /api/auth/basic HTTP/1.1
> Host: api.testingapis.com
> Authorization: Basic YXBpYmVlOnBhc3N3b3JkMTIz
...
{"authenticated":true,"scheme":"basic","user":"apibee"}
This example uses endpoints for signed-in members.Sign inCreate a free account

Base64 is an encoding, not encryption. Anyone who sees the header can turn it back into the password in one command:

Terminal
$ echo YXBpYmVlOnBhc3N3b3JkMTIz | base64 -d
apibee:password123

So Basic auth is only acceptable over HTTPS, and even then the password travels with every single request. When you test an API that uses it, check that plain HTTP is refused or redirected, and check that the credentials never show up in logs or error messages. You can try the encoding yourself with the Base64 tool.

Wrong credentials also give a 401, with a different message: Invalid username or password.That message is fine. A message that said "unknown user" for one case and "wrong password" for the other would tell an attacker which usernames exist.

API keys

An API key is a long random string that identifies a client application rather than a person. Payment providers, map services and weather APIs usually work this way. The key can travel in a header, a query parameter or a cookie, and this API accepts all three on separate endpoints:

Terminal
$ curl -H "X-API-Key: apibee-key-123" https://api.testingapis.com/api/auth/api-key/header
{"authenticated":true,"scheme":"api-key","location":"X-API-Key header"}

$ curl "https://api.testingapis.com/api/auth/api-key/query?api_key=apibee-key-123"
{"authenticated":true,"scheme":"api-key","location":"api_key query parameter"}

$ curl -b api_key=apibee-key-123 https://api.testingapis.com/api/auth/api-key/cookie
{"authenticated":true,"scheme":"api-key","location":"api_key cookie"}
This example uses endpoints for signed-in members.Sign inCreate a free account

The header is the right place. A key in the query string ends up in places nobody thinks of as secret: web server access logs, proxy logs, browser history, analytics tools and the Referer header sent to other sites. If you find an API that takes keys in the URL, it is worth a note in your report even if nothing is broken yet.

Here is what happens with a wrong key:

Terminal
$ curl -i -H "X-API-Key: wrong" https://api.testingapis.com/api/auth/api-key/header

HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8

{"status":401,"error":"Unauthorized","message":"Invalid API key."}
This example uses endpoints for signed-in members.Sign inCreate a free account

A 401, the same as the Basic auth endpoint returns for a wrong password. An invalid key means the server doesn't know who you are, which by the definitions above is a 401. Some well-known APIs return 403 here anyway. What you should check is that the API is consistent with itself and with its documentation, because client code is written against one or the other. Inconsistencies like this are some of the most common findings in auth testing.

Bearer tokens and roles

A bearer token is sent as Authorization: Bearer <token>. The name means that whoever bears the token gets access, with no further proof. Losing a bearer token is like losing a house key.

This API has a few fixed tokens with different roles, which makes it easy to see authorization separately from authentication. The admin endpoint with a user's token:

GET/api/auth/roles/admin
This request uses an endpoint for signed-in members.Sign inCreate a free account

The server knows who you are and refuses anyway:

JSON
{
  "status": 403,
  "error": "Forbidden",
  "message": "This endpoint requires the 'admin' role.",
  "roles": ["user"],
  "scopes": ["read", "write"]
}

Now the same endpoint with the admin token:

GET/api/auth/roles/admin
This request uses an endpoint for signed-in members.Sign inCreate a free account

Roles and scopes are where most authorization bugs live. A role is what the user is (admin, user). A scope is what a token may do (read, write, delete). For every protected endpoint you want at least one test per role that should be allowed and one per role that should not. A small table helps. This is what the three role endpoints on this API return:

TokenGET /roles/userGET /roles/adminDELETE /roles/resource
admin-token200200200
user-token200403403
readonly-token403403403
none401401401

Fill in the expected values from the requirements first, then run the requests and compare. Filling in the table from the actual responses and calling that the expected result is a very common mistake. It turns the test into a record of whatever the API does today.

JWTs

A JSON Web Token is a bearer token with data inside it. You usually get one by logging in:

Terminal
$ curl -X POST https://api.testingapis.com/api/auth/jwt/login \
    -H "Content-Type: application/json" \
    -d '{"username":"user","password":"user123"}'

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2Fw...",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "refreshToken": "jrt_c9239f1c1befa5d736fd92a5d34b2df094b7015e",
  "user": { "username": "user", "role": "user", "name": "Uma User" }
}
This example uses endpoints for signed-in members.Sign inCreate a free account

The access token is trimmed above. In full it is three Base64URL strings joined by dots: a header, a payload and a signature. The payload is readable by anyone. Decode the middle part of the token above and you get:

JSON
{
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "user",
  "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "user",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "user@example.com",
  "jti": "fae783e2-53f4-4572-ae61-f05e6e8c0fc1",
  "nbf": 1791716352,
  "exp": 1791717252,
  "iss": "https://api.testingapis.com",
  "aud": "https://api.testingapis.com"
}
This example uses endpoints for signed-in members.Sign inCreate a free account

The long claim names are the ones ASP.NET uses. Other frameworks use short names like sub and role. The standard ones to know are exp (expiry, in Unix seconds), nbf (not valid before), iss (who issued it) and aud (who it is for). Here exp minus nbf is 900 seconds, which matches the expiresIn of 15 minutes in the login response. That is a quick check worth doing on any login endpoint. You can paste a token into the JWT decoder to read it.

Because the payload is only encoded, a JWT must never hold secrets. Test for that too: log in, decode the token and check for passwords, internal IDs or personal data that the client has no need for.

Send the token to /api/auth/jwt/mesign in to see to see the claims the server accepted, and to /api/auth/jwt/adminsign in to see to see the role check:

Terminal
$ curl -H "Authorization: Bearer $TOKEN" https://api.testingapis.com/api/auth/jwt/me

{"username":"user","role":"user","name":"Uma User","email":"user@example.com","claims":[...]}

$ curl -i -H "Authorization: Bearer $TOKEN" https://api.testingapis.com/api/auth/jwt/admin

HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8

{"status":403,"error":"Forbidden","message":"Your token is valid but lacks the role this endpoint requires."}
This example uses endpoints for signed-in members.Sign inCreate a free account

The 403 comes with the same JSON error shape as the rest of this API, and its message says the token was fine and the role was not. Many frameworks return an empty body here by default, so check: a client that always parses error bodies throws on an empty one, and an error without a message gives the developer nothing to go on.

Expired tokens

Tokens are short-lived on purpose. GET /api/auth/jwt/expiredsign in to see returns a token that was correctly signed but expired an hour ago, so you don't have to wait fifteen minutes to test expiry:

Terminal
$ curl -i -H "Authorization: Bearer $EXPIRED" https://api.testingapis.com/api/auth/jwt/me

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="apibee", error="invalid_token", error_description="The token expired at 2026-10-11T09:59:12Z."

{"status":401,"error":"Unauthorized","message":"The token expired at 2026-10-11T09:59:12Z."}
This example uses endpoints for signed-in members.Sign inCreate a free account

A 401 with error="invalid_token" in WWW-Authenticate. The client can use that to decide to refresh rather than log the user out. Also check a token that expired one second ago, if you can produce one: servers often allow a few minutes of clock skew, and whether that tolerance is 30 seconds or a whole day matters.

Refresh tokens

When the access token expires, the client exchanges the refresh token from the login response for a new pair. A good server rotates refresh tokens: each one works exactly once. This one does. The second request with the same refresh token fails:

Terminal
$ curl -X POST https://api.testingapis.com/api/auth/jwt/refresh \
    -H "Content-Type: application/json" \
    -d '{"refreshToken":"jrt_c9239f1c1befa5d736fd92a5d34b2df094b7015e"}'

{"accessToken":"eyJhbGciOi...","tokenType":"Bearer","expiresIn":900,"refreshToken":"jrt_80fe85394e53de2230543855284f9664ede00450",...}

$ curl -X POST https://api.testingapis.com/api/auth/jwt/refresh \
    -H "Content-Type: application/json" \
    -d '{"refreshToken":"jrt_c9239f1c1befa5d736fd92a5d34b2df094b7015e"}'

{"status":401,"error":"Unauthorized","message":"Refresh token was already used. Every refresh token from this login has been revoked; log in again."}
This example uses endpoints for signed-in members.Sign inCreate a free account

If a used refresh token still works, anyone who stole one old token can keep minting new access tokens indefinitely. Always test the second use. This server goes a step further: a reused refresh token is treated as a sign of theft, and every refresh token from that login is revoked, the newest one included. The refresh tokens lesson covers why, and the problem it causes for apps that refresh twice at once.

Tampering with a token

The signature is what stops a user from editing their own token. The payload says role: user. What if you change it to admin and keep the old signature? This script does exactly that:

JavaScript
// tamper.mjs: change the role claim without re-signing the token
const [header, payload, signature] = process.argv[2].split(".");
const claims = JSON.parse(Buffer.from(payload, "base64url"));
claims["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"] = "admin";
const forged = Buffer.from(JSON.stringify(claims)).toString("base64url");
console.log([header, forged, signature].join("."));
This example uses endpoints for signed-in members.Sign inCreate a free account
Terminal
$ FORGED=$(node tamper.mjs "$TOKEN")
$ curl -i -H "Authorization: Bearer $FORGED" https://api.testingapis.com/api/auth/jwt/admin

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="apibee", error="invalid_token", error_description="The token signature is invalid."

{"status":401,"error":"Unauthorized","message":"The token signature is invalid."}
This example uses endpoints for signed-in members.Sign inCreate a free account

Rejected, as it should be. The server recalculated the signature over the edited payload, it didn't match, and the error description says exactly that. Real APIs have shipped with this check missing, usually because a library was configured to decode tokens without verifying them, or because it accepted the none algorithm. It takes two minutes to test, so test it on every API that uses JWTs.

A negative test checklist

For each protected endpoint, these are the requests to send. The happy path is one test. The rest are the ones that find bugs.

  1. No credentials at all. Expect 401 with WWW-Authenticate.
  2. Wrong credentials: a wrong password, an unknown key, a random token. Expect 401.
  3. A malformed header: Authorization: apibee-token-123 without the word Bearer, Bearerwith nothing after it, Basic with a value that isn't Base64. Expect 401, never 500.
  4. An expired token. Expect 401.
  5. A valid token with too few permissions. Expect 403.
  6. A token with an edited payload and the original signature. Expect 401.
  7. A refresh token used twice. The second use should fail.
  8. After logout or revocation, the old token. It should stop working.

Also look at what the error responses leak. Error messages should not include stack traces, the expected token, or whether a username exists. And watch the timing: if a wrong password for a real user takes 300 ms and a nonexistent user takes 5 ms, the difference tells an attacker which accounts exist, even with identical messages.

One API can use several of these schemes at once. The auth schemes reference lists every one this API supports, including Digest, HMAC signatures and CSRF tokens, which follow the same pattern: a valid request, then each way it can be wrong. Security testing basics goes further into authorization bugs between users.

Exercise

Run the checklist above against GET /api/auth/api-key/headersign in to see (the valid key is apibee-key-123). Write down the status you expect for each item before you send it, then note every response that differs. Some items won't apply to API keys; say which and why.

  • Then log in at POST /api/auth/jwt/loginsign in to see as admin / admin123, decode the token and find the role claim. Call /api/auth/jwt/adminsign in to see with it, and check that exp minus nbf matches expiresIn.
  • Send Authorization: Bearer with a trailing space and no token to /api/auth/bearersign in to see, /api/auth/jwt/mesign in to see and /api/auth/roles/usersign in to see. Compare the three responses. Which one would you rather code a client against, and why?

Practise and go further