Skip to content
Bugwise
Menu
Web Testing

API Testing in Depth: Auth, Negative Tests and Data Checks

Go beyond "status is 200". Test tokens and JWTs, 401 vs 403, IDOR and admin access, negative and boundary requests, response schemas, CRUD chains, pagination, rate limits, ETags and versioning, then hunt 8 bugs in a FoodDash API sandbox.

intermediate Mobile & web
Short on time? Switch to the quick version.

The FoodDash team was proud of its Postman collection: 140 requests, all green, every one checking Status code is 200. Then a developer opened the mobile app’s network log while debugging something else and froze. Every time a customer changed their phone number, the response to PATCH /users/me contained the customer’s password hash, their internal marketing segment and their role.

The status was 200. The tests passed. The bug had been in production for four months.

This lesson picks up where API Testing with Postman stops. You already know methods, status codes, headers, JSON and Postman basics. Now you’ll learn to test the parts that cause the expensive bugs: who is calling, what they’re allowed to touch, what happens with bad input, and what’s really inside a response.

The quick version

1. Know who is calling: authentication

  • API key: a fixed secret that identifies an app or partner, usually sent in a header such as x-api-key. It says which app is calling, not which person.
  • Bearer token: a token you get after logging in and send on every request as Authorization: Bearer <token>. Whoever holds (“bears”) it is treated as you.
  • JWT (JSON Web Token): a common token format with three dot-separated parts, header.payload.signature. The payload is just base64url-encoded JSON, so anyone can decode it and read the user id, role and expiry time (exp). Decoding is not verifying: only the server, with its secret key, can check the signature. If you edit the payload, the signature no longer matches and the server must reject it.
  • Expiry: access tokens expire on purpose (often after 5–60 minutes). An expired token must get 401 on every endpoint.

2. 401 vs 403 vs 404

You sendExpected
No token, broken token, expired token401 Unauthorized (“who are you?”)
A valid token, but your role isn’t allowed403 Forbidden (“I know you, the answer is no”)
A valid token for someone else’s order404 (or 403), never their data

3. Test what users may touch: authorization

With two test accounts, A and B:

  • Horizontal access (IDOR): use A’s token on B’s resource, e.g. GET /orders/1043. Expect 404 or 403.
  • Vertical access: use a normal customer token on an admin endpoint, e.g. GET /admin/users. Expect 403.
  • Sneaky fields: send "role": "admin" in a profile update. It must be ignored or rejected.

4. Break the request on purpose

Change one thing at a time: remove a required field, add an unknown field, send "2" instead of 2, send "" and null and leave the field out (three different cases), send huge values, an invalid enum value, broken JSON and the wrong Content-Type. Expect a 4xx with a clear message. A 500 for bad input is a bug. Send the same POST twice: does it create two orders? Ask whether the API supports an Idempotency-Key header.

5. Read the whole response, not just the status

Check the schema (field names, types, required fields), the values (does the total add up?), consistency with the database and the UI, error message quality, and leaks: password hashes, internal IDs, other users’ data, stack traces.

6. Chain requests and check the edges

Create → read → update → read → delete → read again (expect 404), passing the new id along in a variable. Check pagination (no item twice, none missing), filters and sorting, 429 with Retry-After on rate limits, and that old app versions still work when the API changes.

Authentication in practice

AuthenticationAuthenticationChecking who a user is, usually with a password, a code or biometrics.Open in glossary → answers “who are you?”. APIs don’t have login screens, so every request has to carry proof. You’ll meet three kinds.

API keys

An API keyAPI keyA fixed secret string that identifies an app or partner calling an API, usually sent in a header.Open in glossary → is a long, fixed secret string issued to an application, a partner or a developer account, for example a restaurant’s point-of-sale system that pushes menu updates to FoodDash. It’s usually sent in a header:

GET /v1/partner/menus HTTP/1.1
Host: api.fooddash.test
x-api-key: test_pk_7Hq2...

What to test:

  • No key, a wrong key, and a revoked key all return 401.
  • A key for partner A can’t change partner B’s menu (403 or 404).
  • Keys are not accepted in the URL (?api_key=...) if the docs say header only. URLs end up in logs and browser history.
  • The key never appears in responses, error messages or the mobile app’s public code (ask developers how it’s protected).

Bearer tokens

After a user logs in (for example POST /auth/login with email and password), the server returns an access token, sent as a bearer tokenBearer tokenAn access token sent as Authorization: Bearer <token>; whoever holds it is treated as the user.Open in glossary →. The app sends it on every request:

GET /v1/users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzEwMDEi...

“Bearer” means whoever holds this token is treated as the owner. Like a concert ticket, nobody checks your face, they check the ticket. That’s why tokens are treated like passwords: never in screenshots, public tickets or shared collections without masking. Tokens can be opaque (a random string the server looks up in a database, much like a session tokenSession tokenA string the server issues after login that proves who you are on later requests.Open in glossary →) or self-contained, which usually means a JWT.

JWT: what’s inside

A JWTJWTJSON Web Token: a signed token with three base64url parts (header, payload, signature) that APIs use to identify the caller.Open in glossary → (JSON Web Token, say “jot”) is three base64url-encoded parts joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzEwMDEiLCJyb2xlIjoiY3VzdG9tZXIiLCJleHAiOjE3OTIyMzA2MDB9.x3Fh9…Qe8

Header

Which algorithm signed it.

{ "alg": "HS256",
"typ": "JWT" }

Payload (claims)

Who, what role, until when.

{ "sub": "u_1001",
"role": "customer",
"exp": 1792230600 }

Signature

Made from the header, the payload and a secret key only the server knows (or a private key).

The payload holds claims. The standard ones you’ll see most:

ClaimMeaningExample
subSubject: the user id"u_1001"
expExpires at (seconds since 1 Jan 1970, UTC)1792230600
iatIssued at1792227000
iss / audIssuer / intended audience"https://auth.fooddash.test"

Apps add their own claims too, like role, name or scope.

How to decode one. Copy the token from Postman or from DevTools › Network › a request › Headers › Authorization. Then paste it into a JWT decoder. The sandbox below has one built in. Online decoders such as jwt.io decode in your browser, but company policy may still forbid pasting tokens into external sites, so use test tokens only and ask first. Things to look at:

  • Is exp there, and is the lifetime what the team agreed (15 minutes, not 15 years)?
  • Is the role or scope right for this test user?
  • Is there anything that shouldn’t be readable by the user, such as an email of another person, an internal flag or a password? A JWT is encoded, not encrypted. Anyone holding it can read it.

Token expiry and refresh

Access tokens are short-lived so a stolen token stops working soon. To avoid logging users out every 15 minutes, many apps also get a refresh tokenRefresh tokenA longer-lived token used only to get a new access token when the old one expires.Open in glossary →: a longer-lived token used only to ask the auth server for a new access token (POST /auth/refresh).

  1. Expired access token → 401. Use an expired token (ask developers for a test token with a short lifetime, or wait). Every protected endpoint must return 401, not just most of them. Check the response: per the HTTP spec a 401 comes with a WWW-Authenticate header, often with error="invalid_token".
  2. The app refreshes quietly. In the app, wait until the access token expires, then tap something. The app should get a new token in the background and retry once. The user shouldn’t see an error or get logged out (watch it in a proxy tool or DevTools).
  3. Refresh after logout fails. Log out, then try the old refresh token. Expected: 401. Logout must revoke it on the server, not just delete it on the device.
  4. Refresh token rotation. If the team uses rotation, each refresh returns a new refresh token and the old one stops working. Try the old one again.
  5. Password change. After changing the password on another device, do old tokens stop working (now, or when the access token expires)? Ask what the requirement is.

401 vs 403, tested properly

TestRequestExpected
No credentialsNo Authorization header401 + WWW-Authenticate
MalformedAuthorization: Bearer abc, or Token xyz401
ExpiredA token whose exp is in the past401, on every endpoint
TamperedEdited payload, old signature401
Valid, wrong roleCustomer token on GET /admin/users403
Valid, not the ownerA’s token on B’s order404 (or 403, see below)

Repeating the request with the same credentials won’t fix a 403. A 401 means “authenticate (again)”, so apps usually react by refreshing the token or sending the user to the login screen. If the app logs users out on a 403, that’s a client bug worth reporting.

Authorization tests

AuthorizationAuthorizationChecking what a logged-in user is allowed to see or do.Open in glossary → answers “what may you do?” It must be checked by the server on every request, because anyone can send any request with their own valid token. Hiding a button in the app is not a security control.

You need at least two test accounts with the same role (Anna and Ben, both customers) and, ideally, one per role (customer, courier, restaurant owner, support agent, admin).

Horizontal access: same role, someone else’s data (IDOR)

  1. As Ben, create or find something that belongs to Ben: GET /orders → order 1043.
  2. As Anna, request Ben’s object directly: GET /orders/1043.
  3. Expected: 404 Not Found (or 403). Never Ben’s name, phone, address or items.
  4. Repeat for every method and every object type: PATCH /addresses/21, DELETE /addresses/21, GET /invoices/..., GET /users/u_1002. Teams often protect GET and forget PATCH or DELETE.
  5. Also try IDs hidden in query parameters and bodies, not only in paths: GET /orders?userId=u_1002, or "addressId": 21 inside Anna’s new order.

This bug is called an IDORIDORInsecure Direct Object Reference: reaching another user's data by changing an ID in a URL or request.Open in glossary →, a form of broken access controlBroken access controlUsers can see or do things their role should not allow.Open in glossary →. Sequential IDs (1041, 1042, 1043) make it easier to exploit, but random IDs (UUIDs) don’t fix it. The ownership check does.

Vertical access: a normal user calling admin endpoints

List the privileged endpoints (from the OpenAPIOpenAPIA standard file format that describes an API's endpoints, parameters and responses (formerly called Swagger).Open in glossary → docs or by watching an admin session in DevTools): GET /admin/users, POST /refunds, PATCH /restaurants/3/commission. Call each one with a customer token. Expected: 403, and no side effect. Check afterwards that the refund wasn’t created anyway.

Sneaky fields (mass assignment)

Some APIs copy every field from the request body straight into the database. That bug is called mass assignmentMass assignmentA bug where an API saves every field from the request body, including ones the user must not change.Open in glossary →. Then a customer can send:

{ "name": "Anna", "role": "admin", "balance": 9999, "emailVerified": true }

Expected: unknown or protected fields are ignored (or rejected with 400), and a follow-up GET shows they didn’t change. This is often called mass assignment.

A role × endpoint matrix

Write it once, then test each cell:

EndpointAnonymousCustomer (own)Customer (other’s)Admin
GET /orders/{id}401200404200
DELETE /addresses/{id}401204404204
GET /admin/users401403403200

Negative and boundary testing of requests

The server is the last line of defense: front-end checks can be skipped by anyone with Postman. Apply equivalence partitioning and boundary value analysis to every field, and break one thing at a time so you know which change caused the result.

Start from a valid request:

{
  "restaurantId": 1,
  "items": [{ "menuItemId": 101, "quantity": 2 }],
  "paymentMethod": "card",
  "note": "Leave at the door"
}
CategoryTryGood API answers
Missing fieldRemove restaurantId, then items, then paymentMethod (one per request)400 naming the field, for each of them
Extra fieldAdd "total": 0, "status": "delivered", "discount": 100Ignored (the server calculates them) or 400. Never applied.
Wrong type"quantity": "2", "restaurantId": "1", "items": {}, "quantity": 2.5400 “must be an integer”. Silently converting is a team decision, so ask.
Empty vs null vs missing"note": "", "note": null, no note at allEach behaves as documented. They are three different cases.
Boundariesquantity 0, 1, 20, 21, -11 and 20 accepted, the rest 400
Huge valuesA 10,000-character note, quantity: 99999999999, 1,000 items400 or 413 Content Too Large. Not 500, not a timeout.
Invalid enum"paymentMethod": "bitcoin", "CARD", ""400 listing the allowed values
Malformed JSONRemove a closing brace, add a trailing comma400 “invalid JSON”. No stack trace.
Wrong Content-TypeSend the JSON with Content-Type: text/plain415 Unsupported Media Type (some APIs answer 400)
Unknown references"restaurantId": 999, a menu item from another restaurant404 or 422 with a clear message
Special charactersEmoji, accents, <script>, ' OR 1=1 -- in text fieldsStored and returned exactly as sent, never executed (see Security Basics)

Duplicate submissions and idempotency keys

On a train with bad reception, the app sends POST /orders, the response gets lost, and the app (or the impatient user) sends it again. Without protection, the customer gets two dinners and two charges. That’s the API side of a double submitDouble submitA bug where an action runs twice because the user tapped or clicked twice quickly.Open in glossary →.

POST isn’t idempotentIdempotencyAn action that gives the same result no matter how many times it is repeated.Open in glossary → by nature, so many APIs accept an Idempotency-Key header (an idempotency keyIdempotency keyA unique value the client sends with a request so the server can recognize a retry and not repeat the action.Open in glossary →): a unique value the client generates once per user action (often a UUID). If the server sees the same key again, it returns the first response instead of creating a second order. Stripe and many payment APIs work this way, and there’s an IETF draft standard for the header.

Tests:

  • Same key, same body, sent twice → the same order id both times, only one order in GET /orders.
  • Same key, different body → an error (often 422 or 409), not a silent second order.
  • Different keys → two orders (that’s correct: two real actions).
  • No key, if the API requires one for payments → 400.

Validating responses beyond the status code

A 200 only says “the server didn’t complain”. Check the response in layers:

  1. Status and headers. The right code (201 + a Location header after a create, 204 with an empty body after a delete). Content-Type: application/json. Cache-Control: no-store on personal data.
  2. Schema. Every documented field is there, names are spelled as documented (deliveryFee, not delivery_fee on one endpoint and deliveryFee on another), types are right (total is a number, id is the same type everywhere), required fields are never null.
  3. Values and business rules. total = sum of unitPrice × quantity + deliveryFee. Dates are in the documented format and time zone. Currencies match. Enums only contain documented values.
  4. Consistency. What the API returns matches the database (see SQL for Testers) and what the UI shows. If the app shows €24.99 and the API says 24.98, one of them rounds wrongly.
  5. Error quality. Errors use one consistent shape, name the field and the rule, and are written for humans.
  6. No leaks. No password hashes, tokens, internal IDs or flags, other users’ data, stack traces, SQL, server paths or framework versions.

A useful error

{
  "error": "validation_error",
  "message": "Some fields are invalid.",
  "details": [
    { "field": "items[0].quantity", "message": "must be from 1 to 20" }
  ],
  "requestId": "req_8f2k1"
}

A useless or leaky error

{
  "error": "TypeError: Cannot read properties of undefined (reading 'map')",
  "stack": "at OrdersController.create (/srv/api/src/orders.js:42:9)"
}

Schema checks in Postman

A JSON SchemaJSON SchemaA JSON document that describes what valid JSON looks like: fields, types, required properties and allowed values.Open in glossary → is a JSON document that describes what valid JSON looks like: which fields, which types, which are required. Many teams generate it from their OpenAPI file. Postman can check a response against one:

const orderSchema = {
  type: "object",
  required: ["id", "status", "items", "total", "currency"],
  properties: {
    id: { type: "integer" },
    status: { enum: ["placed", "preparing", "on_the_way", "delivered", "canceled"] },
    total: { type: "number", minimum: 0 },
    currency: { type: "string", pattern: "^[A-Z]{3}$" },
    items: { type: "array", minItems: 1 }
  },
  not: { anyOf: [{ required: ["passwordHash"] }, { required: ["stack"] }] }
};

pm.test("Order matches the schema", () => {
  pm.response.to.have.jsonSchema(orderSchema);
});

The last rule (not … required) fails the test if a passwordHash or stack field ever appears. A one-line guard like that would have caught the FoodDash leak from the intro on day one.

Chaining requests: a CRUD flow with variables

Real tests are sequences: you create something, then use its id in the next request. That’s CRUDCRUDCreate, Read, Update, Delete: the four basic operations on stored data.Open in glossary →: Create, Read, Update, Delete.

  1. Create: POST /v1/addresses → 201. Save the new id: in Postman’s Scripts › Post-response, pm.environment.set("addressId", pm.response.json().id);
  2. Read: GET /v1/addresses/{{addressId}} → 200 with exactly what you sent.
  3. Update: PATCH /v1/addresses/{{addressId}} with a new label → 200.
  4. Read again: the label really changed, the other fields didn’t.
  5. Delete: DELETE /v1/addresses/{{addressId}} → 204 (or 200).
  6. Read after delete: GET /v1/addresses/{{addressId}} → 404, and the address is gone from GET /v1/addresses. Skipping this step is how “delete” bugs escape.

Save the steps in one Postman collection folder and run it with the Collection Runner (or Newman, Postman’s command-line runner, in CI). Each run creates its own data and cleans up after itself, so it can run again and again.

Environments. A Postman environment is a named set of variables. You keep one for Dev and one for stagingStagingA pre-release environment that mirrors production as closely as possible.Open in glossary →, each with its own baseUrl and test-account tokens, and switch between them in the top-right dropdown. The requests themselves never change. Two habits:

  • Keep tokens and keys in variables marked as secret and don’t share real secrets in team workspaces. Use test accounts only.
  • Never point a destructive collection (one that deletes things) at production by accident. Some teams add a pre-request check that stops if baseUrl contains prod.

Lists: pagination, filtering and sorting

List endpoints break in quiet ways. A user never notices that restaurant #7 is missing, they just never order from it. (For the UI side, see the Pagination component.)

Page or offset paginationPaginationSplitting a long list into pages that load one at a time.Open in glossary → (?page=2&limit=20, or ?offset=20&limit=20):

Filtering and sorting (?cuisine=italian&sort=-rating):

  • Every returned item matches the filter. Combine two filters. Try a filter value that matches nothing (expect 200 with an empty list).
  • The sort order is right, including ties (two restaurants with rating 4.5 must keep a stable order across pages) and descending (-rating).
  • Unknown sort fields or invalid values give 400, not a silently ignored parameter.

Rate limiting: 429 and Retry-After

rate limitingRate limitingCapping how many requests a user or device can make in a period of time.Open in glossary → caps how many requests a client may send in a time window. When you go over, the API should answer 429 Too Many Requests, ideally with a Retry-After header that says how long to wait, in seconds (Retry-After: 30) or as a date. Many APIs also send headers like X-RateLimit-Limit and X-RateLimit-Remaining (common, but not a formal standard).

What to test, after agreeing on it with the team:

The Security Basics lesson covers which features need rate limits (login, codes, coupons).

Caching and ETags

Responses can be cached so the app doesn’t download the same restaurant menu 50 times. An ETagETagA version label the server sends with a response so clients can ask 'has this changed?' and get 304 if not.Open in glossary → is a version label the server puts on a response, like ETag: "r3-v3". Next time, the client sends it back in If-None-Match. If nothing changed, the server answers 304 Not Modified with an empty body and the client uses its cachedCacheStored copies of data or files that make apps and websites load faster.Open in glossary → copy.

Testers check:

  • The ETag changes when the data changes. If a restaurant updates its menu and you still get 304 with the old ETag, users see stale prices.
  • Personal data (profiles, orders) is not cached by shared caches: look for Cache-Control: no-store or private.
  • For updates, some APIs use If-Match: “only save if the version is still X”. A stale ETag must get 412 Precondition Failed, which prevents two support agents from overwriting each other’s changes.

Versioning and backward compatibility

APIs change, but old clients don’t disappear. A mobile app from last year is still installed on thousands of phones and keeps calling the old API. That’s why APIs are versioned (API versioningAPI versioningKeeping several versions of an API (such as /v1 and /v2) so old apps keep working after breaking changes.Open in glossary →), usually in the path (/v1/orders, /v2/orders) or in a header.

Non-breaking (usually fine in v1)Breaking (needs a new version)
Adding an optional field to a responseRemoving or renaming a field
Adding a new endpointChanging a field’s type ("price": 12.5 → "12.50")
Adding an optional request parameterMaking an optional request field required
Adding an enum value (careful: old apps may crash on unknown values)Changing what a status code or error means

Test ideas: run your v1 collection against every new release (it’s your regression suite for old apps), check that an unknown version like /v3 returns a clear 404, and install the oldest supported app version against the new back end before a release.

Contract testing automates part of this. The client team writes down exactly which requests it sends and which response fields it relies on (the contract), and the API’s build checks every change against those contracts, so a renamed field fails the build before it reaches users. Tools such as Pact do this; it’s mostly set up by developers, but knowing it exists helps you ask “is this endpoint covered by a contract testContract testingChecking that two services keep to their agreed API contract: fields, types and status codes.Open in glossary →?”

Example test cases

TC-API2-001Expired token is rejected on every order endpoint

Preconditions

  • Test token for Anna whose exp is in the past

Steps

  1. Send GET /v1/orders with the expired token
  2. Send GET /v1/orders/1041 with the same token
  3. Send POST /v1/orders with a valid body and the same token

Expected result

All three return 401 with WWW-Authenticate: Bearer error="invalid_token". No data, no order created.

TC-API2-002Customer cannot read another customer's order

Preconditions

  • Test users Anna and Ben
  • Ben owns order 1043

Steps

  1. Send GET /v1/orders/1043 with Anna's token

Expected result

404 Not Found, same body shape as a truly missing order. None of Ben's data in the response.

TC-API2-003Each missing required field returns 400

Steps

  1. Start from a valid POST /v1/orders body
  2. Remove restaurantId and send
  3. Restore it, remove items and send
  4. Restore it, remove paymentMethod and send

Expected result

Each request returns 400 with a details entry naming the missing field. No 500, no order created.

TC-API2-004Profile update returns only public fields

Steps

  1. Send PATCH /v1/users/me with {"phone": "+1 555 0177"}
  2. Compare the response with GET /v1/users/me

Expected result

200 with the same fields as GET (id, name, email, phone, createdAt). No passwordHash, role or internal fields.

TC-API2-005Deleted address is really gone

Steps

  1. POST /v1/addresses and save the id
  2. DELETE /v1/addresses/{{addressId}}
  3. GET /v1/addresses/{{addressId}}
  4. GET /v1/addresses

Expected result

DELETE returns 204 (or 200). The follow-up GET returns 404 and the list no longer contains the id.

TC-API2-006Pagination returns every restaurant exactly once

Steps

  1. GET /v1/restaurants?page=1&limit=3
  2. Repeat for page 2, 3 and 4
  3. Collect all ids

Expected result

Pages 1–3 together contain each restaurant id once and the count equals total. Page 4 returns 200 with an empty data array.

Tip: click a test case to expand it.

Hands-on: the FoodDash API sandbox

Below is a FoodDash test build running entirely in your browser: a mock server, two test customers, Anna and Ben, and a token for Anna that expired an hour ago. Nothing goes over the network, so you can break whatever you like. The API docs at the bottom of the sandbox say what the API promises. Every difference between the docs and what you see is a finding.

Start with this tour, then hunt for the rest.

  1. Warm up. Send List restaurants (page 1). Open the response Headers and find Cache-Control.
  2. ETag. Load Restaurant details (ETag) and send. Copy the ETag value (with its quotes), add a header If-None-Match with that value, and send again. You should get 304 and an empty body.
  3. 401 vs 403. Load My profile. Send it as Anna, then No token (look at WWW-Authenticate), then Anna (expired). Then load Admin: list users as Anna.
  4. JWT. Open the JWT decoder at the bottom. Compare exp in annaToken and expiredToken. Click the button that edits the role to admin, then send the admin request. What does the server say?
  5. CRUD chain. Send Create address: the new id is saved as {{addressId}}. Then read it, update it, delete it, and read it again. Does every step match the docs?
  6. Break an order. Load Place an order and change one thing at a time from the negative-testing table above: a wrong type, an invalid paymentMethod, a missing field, a boundary quantity, broken JSON, Content-Type: text/plain.
  7. Duplicates. Add the header Idempotency-Key: order-001 and send Place an order twice. Compare the ids.
  8. Rate limit. Send Redeem promo code with a wrong code six times in a row. Find Retry-After.

When a request exposes one of the planted bugs, the sandbox says so and ticks it in the list. The bugs are deterministic: the same request always shows the same bug.

🐞 Bug hunt: FoodDash API test build

This demo has 8 hidden bugs. Explore it like a tester, then check your findings.

Found0/8
FoodDash API ›
Send as

GET https://api.fooddash.test/v1/restaurants?page=1&limit=3

Try Idempotency-Key on POST /v1/orders, or If-None-Match with an ETag on a restaurant.

Response

Pick a request and click Send.

History

Requests you send appear here.

  1. 1

    Hint: Find an order that belongs to Ben, then ask for it with Anna's token.

  2. 2

    Hint: Send as "Anna (expired)" to several endpoints, not just one.

  3. 3

    Hint: The docs say quantity is 1–20. Test both ends of that range and beyond.

  4. 4

    Hint: Break the JSON syntax of any request body, then read the whole error body.

  5. 5

    Hint: Remove required fields from Place an order one at a time and compare the answers.

  6. 6

    Hint: Compare what GET and PATCH on /v1/users/me return.

  7. 7

    Hint: After deleting something, try to read it again.

  8. 8

    Hint: Walk through the pages of the restaurant list and write down the ids.

Writing an API bug report

An API bug report is a normal bug report with the request and response as the steps and evidence. A developer should be able to copy one command and see the bug.

  1. Title: endpoint + what’s wrong + under which condition. “POST /v1/orders returns 500 when items is missing”.
  2. Environment: base URL (staging, not “the server”), build or version, test account (never a real password).
  3. Request: method, full URL, relevant headers, body. Best as a curl command (Postman: the code icon </> › cURL. DevTools: right-click a request › Copy › Copy as cURL). Mask the token.
  4. Response: status, the relevant headers and the body (shortened if long). Include the X-Request-Id or timestamp so developers can find the server log.
  5. Expected vs actual, quoting the API docs if you can.
  6. Severity and reproducibility (it’s an API, so usually 100%).

A practical API test checklist

API testing in depth

0 of 19 checked

Check your understanding

Quick quiz

0/6 answered · 0 correct

  1. 1.You decode a JWT and change "role": "customer" to "admin", keeping the old signature. What should the API return when you call an admin endpoint?

  2. 2.Logged in as Anna, you send GET /orders/1043, which belongs to Ben. Which result is a bug?

  3. 3.POST /orders without "items" returns 500. Without "paymentMethod" it returns 400. What is the best conclusion?

  4. 4.A profile update returns 200 with the right new phone number. What else should you check?

  5. 5.The app sends POST /orders, the response is lost on a bad network, and the app retries. What prevents a second order?

  6. 6.You hit a rate limit. Which response is correct?

Key takeaways

  • Authentication: API keys identify apps, bearer tokens identify users. A JWT’s payload is readable by anyone; decoding is not verifying. Expired, malformed and edited tokens must get 401 on every endpoint.
  • 401 vs 403: 401 = we don’t know who you are; 403 = we know you, and the answer is no. Someone else’s object → 404 or 403, never their data.
  • Authorization: with two test accounts, test horizontal access (IDOR) and vertical access (admin endpoints) for every method, plus sneaky fields like role. Only on test environments, with permission.
  • Negative tests: change one thing at a time: missing, extra, wrong-type, empty vs null, huge, invalid enum, malformed JSON, wrong Content-Type, duplicates. Bad input → 4xx with a clear message, never 500.
  • Responses: check the schema, types, values, consistency with the database and UI, error quality and leaks, not just the status.
  • Flows and edges: chain CRUD with variables and read after delete; walk every page; check filters and sorting, 429 + Retry-After, ETags and older API versions.
  • Bug reports: a curl command with a masked token, the response, the environment and the request id.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its review cards are now in Bites ⚡
Esc

Type to search the whole site.