PRACTICAL GUIDE / Next.js API 401 403 404 testing

Make 401, 403, and 404 mean different things in Next.js

Test protected Next.js route handlers with distinct identities, exact JSON contracts, and evidence that exposes redirects, auth-order bugs, and leaks.

By The Testing AcademyUpdated August 4, 202620 min read
All field guides
In this guide6 sections
  1. Decide what each status means before writing the test
  2. Keep authentication, authorization, and lookup in the right order
  3. Exercise three identities, not three URLs
  4. Read the failure evidence before changing the assertion
  5. Roll the contract into CI without making it brittle
  6. Know when 401, 403, and 404 are the wrong contract

What you will learn

  • Decide what each status means before writing the test
  • Keep authentication, authorization, and lookup in the right order
  • Exercise three identities, not three URLs
  • Read the failure evidence before changing the assertion

A logged-out request and a signed-in viewer both receive 404 from the same admin endpoint. The tests stay green because they only check that response.ok() is false. The UI looks protected, but nobody can tell whether authentication, authorization, or resource lookup made the decision.

Those branches are different product contracts. A useful Next.js API 401 403 404 testing strategy exercises different caller identities against one route, reads the response without a browser, and proves the handler stops at the intended boundary. A status code alone is useful, but the status, headers, body, and server-side decision order together tell you whether the API is behaving deliberately.

Decide what each status means before writing the test

The words around these statuses are historically confusing, so use operational definitions in the test plan. A 401 response means the request does not have valid authentication for the protected resource. A 403 response means the service recognizes the caller but refuses the requested action. A 404 response means the selected resource was not found, or the product has deliberately chosen the same response to conceal a resource from that caller.

MDN’s HTTP authentication guide distinguishes invalid or missing credentials from valid credentials with insufficient access. It also notes that a service may return 404 instead of 403 to avoid acknowledging that a resource exists. That concealment is a policy choice, not a universal security trick. Document which endpoints use it and test both sides of the choice.

Write the contract in terms of actors. “Anonymous caller gets 401” is testable. “Authenticated viewer gets 403 from the admin collection” is testable. “Authorized editor gets 404 for an unknown project ID” is testable. “Unauthorized requests fail safely” is too broad because all three statuses could satisfy it while the route makes the wrong decision.

The body needs a stable machine contract as well. Human messages change for tone or localization. Codes such as UNAUTHENTICATED, FORBIDDEN, and PROJECT_NOT_FOUND can remain stable for clients and tests. Keep the public object small. If every branch returns a different shape, callers need special parsing before they can even identify the error.

For bearer-token authentication, a 401 response should include the authentication challenge required by that scheme. The example below returns WWW-Authenticate: Bearer. If your application uses a different authentication model, specify its response contract rather than copying this header without understanding it.

Do not turn 404 into a catch-all. Invalid JSON is generally a request-shape problem. An unsupported method is a method problem. A rate limit is not a missing resource. A database outage is not proof that an ID does not exist. Tests become useful when each boundary has one public meaning and nearby errors cannot masquerade as it.

Worked example one is an admin listing endpoint. Anonymous callers receive 401. Signed-in nonstaff callers receive 403 without a database lookup. Staff callers reach the query and can receive 200. There is no resource ID to conceal, so returning 404 to every nonstaff caller would make the contract harder to diagnose without adding resource privacy.

Worked example two is a project details endpoint for users with a global project:read:any permission. The handler can authenticate, check the global permission, and then look up the project. A missing project is 404 only for a caller who reached the lookup. A viewer without global permission gets 403 for both real and invented IDs, so the response does not reveal which one exists.

Worked example three is a tenant-scoped resource where existence itself is private. After authentication, the repository can perform a tenant-scoped lookup. Both a nonexistent ID and an ID belonging to another tenant return the same 404 object. In that design, there is no separate 403 at the resource boundary, although a higher-level action such as accessing all tenant projects can still return 403.

These examples are not interchangeable. Pick one policy for each route and encode it in tests before a refactor changes the order accidentally.

Keep authentication, authorization, and lookup in the right order

Next.js Route Handlers use the Web Request and Response APIs in a route.ts file under the app directory. In current Next.js 16 documentation, dynamic route parameters are provided as a promise and are awaited inside the handler. Using the installed framework’s contract matters because copying an older synchronous parameter example can turn an authorization test into a framework error.

The clean design is a handler with explicit dependencies. Authentication produces an actor or no actor. Authorization checks that actor’s permission. Repository lookup runs only after those gates. Dependency injection also lets a unit test count calls, which is the strongest way to prove an unauthorized request never touched the repository.

This complete handler factory implements the global-permission example. The application wires its real authenticate and findProject functions into the exported GET. Those functions are application code, not Next.js APIs.

TypeScript
type Actor = {
  id: string;
  permissions: readonly string[];
};

type Project = {
  id: string;
  name: string;
};

type Dependencies = {
  authenticate(request: Request): Promise<Actor | null>;
  findProject(id: string): Promise<Project | null>;
};

type RouteContext = {
  params: Promise<{ id: string }>;
};

export function createGetProject(deps: Dependencies) {
  return async function GET(request: Request, context: RouteContext) {
    const actor = await deps.authenticate(request);

    if (!actor) {
      return Response.json(
        { error: { code: "UNAUTHENTICATED" } },
        {
          status: 401,
          headers: { "WWW-Authenticate": "Bearer" },
        },
      );
    }

    if (!actor.permissions.includes("project:read:any")) {
      return Response.json(
        { error: { code: "FORBIDDEN" } },
        { status: 403 },
      );
    }

    const { id } = await context.params;
    const project = await deps.findProject(id);

    if (!project) {
      return Response.json(
        { error: { code: "PROJECT_NOT_FOUND" } },
        { status: 404 },
      );
    }

    return Response.json({ project });
  };
}

An unauthorized branch must have an assertion that can fail if lookup order changes. In a unit test, provide a repository function that increments a counter or throws when called, then assert the count remains zero for 401 and 403. That oracle observes behavior outside the hard-coded response fixture. If a refactor moves findProject above the permission check, the test fails.

Do not infer lookup order from response time. Timing varies with process load, caches, network conditions, and test runners. A mock or fake dependency can prove the call did not happen. An integration test can confirm the public response. Use both levels for a high-risk route instead of turning latency into a fragile side-channel assertion.

For the tenant-concealment variant, scope the query itself: findProjectForTenant(id, actor.tenantId). Return the same 404 object when it returns null. Avoid fetching an unrestricted project and then placing its name, owner, or database key in a debug response before the membership check. Server logs may retain private context under controlled access, but the client boundary must stay identical.

Add a unit-level call-order test before relying on the deployed test. Inject an authenticate function that returns null and a findProject spy that throws if invoked. The response must be 401 and the spy must have zero calls. Repeat with a viewer actor and expect 403 with zero calls. Finally inject an authorized actor, return null from the repository, and expect one call plus 404. Unlike a timing assertion, those observations fail immediately when a refactor moves the query above a gate.

Include an authorized success control. A suite containing only negative cases can stay green when the route is disconnected and every request is rejected. The editor should receive 200 for the known project, and the returned ID should equal the independent seeded ID. That proves the token, base URL, route deployment, repository fixture, and permission setup are capable of reaching the protected operation.

There is a trade-off in concealment. A uniform 404 reveals less about resource existence to the caller, but it gives legitimate users less precise feedback and makes support diagnosis depend on correlated server logs. A clear 403 improves client behavior when the resource is known and only the action is forbidden. Product and security owners should choose that cost, not whichever status was easiest to return from a shared helper.

Exercise three identities, not three URLs

One route under three identities exposes ordering mistakes better than three unrelated endpoints. Use a stable existing ID for the 401 and 403 cases because the route should stop before lookup. Use an intentionally absent ID for the authorized 404 case. Provision that absent ID as part of the test environment contract so a future seed cannot quietly create it.

Playwright’s request fixture is an isolated APIRequestContext. The official API request documentation states that response objects are returned for error statuses by default. Set failOnStatusCode: false explicitly in contract tests so a shared configuration change cannot convert the expected 401 into a thrown request error before assertions run.

Disable redirects for these calls. If middleware redirects an API request to a sign-in page, automatic redirect following can leave the test holding a final 200 HTML response. That is not a 401. Setting maxRedirects: 0 exposes the 3xx response directly and keeps the error helper from accepting a login page as JSON.

The helper below reads the body once, attaches the exact text to the test report, verifies JSON content type, parses it, and asserts the entire public error object. It uses headersArray(), which Playwright documents as preserving header names without forcing lower case, then performs a case-insensitive name comparison.

TypeScript
import {
  expect,
  type APIResponse,
  type TestInfo,
} from "@playwright/test";

type ErrorCode =
  | "UNAUTHENTICATED"
  | "FORBIDDEN"
  | "PROJECT_NOT_FOUND";

export async function expectJsonError(
  response: APIResponse,
  expectedStatus: number,
  expectedCode: ErrorCode,
  testInfo: TestInfo,
) {
  const rawBody = await response.text();
  await testInfo.attach("api-error-response", {
    body: rawBody,
    contentType: "text/plain",
  });

  const contentType = response
    .headersArray()
    .find((header) => header.name.toLowerCase() === "content-type")
    ?.value;

  expect(response.status()).toBe(expectedStatus);
  expect(contentType).toContain("application/json");
  expect(JSON.parse(rawBody)).toEqual({
    error: { code: expectedCode },
  });
}

The TestInfo attachment API copies the supplied body into test output for reporters. Marking this raw evidence as plain text does not change the server response. That is why the helper separately reads and asserts the actual response header. If the endpoint returns HTML, the content type assertion is the one that fails, reporting an expected substring of application/json against a received string of text/html; charset=utf-8. JSON.parse never runs, because the assertion above it has already ended the test. The ordering is deliberate for a different reason: the attachment happens first, so the report carries the sign-in page the server actually sent no matter which assertion stops the run.

Now exercise anonymous, viewer, and editor identities. The example assumes the API accepts bearer tokens and CI provides a viewer token without project:read:any, plus an editor token with that permission. A missing environment value fails setup instead of skipping a security contract silently.

TypeScript
import { expect, test } from "@playwright/test";
import { expectJsonError } from "./expect-json-error";

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(name + " is required");
  }
  return value;
}

const existingProjectId = requiredEnv("EXISTING_PROJECT_ID");
const missingProjectId = requiredEnv("MISSING_PROJECT_ID");
const viewerToken = requiredEnv("VIEWER_API_TOKEN");
const editorToken = requiredEnv("EDITOR_API_TOKEN");

test("anonymous caller receives 401", async ({ request }, testInfo) => {
  const response = await request.get(
    "/api/projects/" + encodeURIComponent(existingProjectId),
    {
      failOnStatusCode: false,
      maxRedirects: 0,
    },
  );

  await expectJsonError(response, 401, "UNAUTHENTICATED", testInfo);
  expect(
    response
      .headersArray()
      .find((header) => header.name.toLowerCase() === "www-authenticate")
      ?.value,
  ).toBe("Bearer");
});

test("viewer receives 403 before lookup", async ({ request }, testInfo) => {
  const response = await request.get(
    "/api/projects/" + encodeURIComponent(existingProjectId),
    {
      headers: { Authorization: "Bearer " + viewerToken },
      failOnStatusCode: false,
      maxRedirects: 0,
    },
  );

  await expectJsonError(response, 403, "FORBIDDEN", testInfo);
});

test("authorized editor receives 404 for an absent id", async (
  { request },
  testInfo,
) => {
  const response = await request.get(
    "/api/projects/" + encodeURIComponent(missingProjectId),
    {
      headers: { Authorization: "Bearer " + editorToken },
      failOnStatusCode: false,
      maxRedirects: 0,
    },
  );

  await expectJsonError(response, 404, "PROJECT_NOT_FOUND", testInfo);
});

These tests can fail for meaningful changes. Removing authentication turns the first response into 200 or another branch. Granting the viewer permission reaches lookup and changes 403. Returning a generic object or HTML changes the exact body. Redirecting to sign-in produces 3xx because redirects are disabled. Accidentally seeding the reserved missing ID produces 200.

Do not put a valid editor token in a global extraHTTPHeaders configuration for this file. The anonymous case would inherit it and stop testing anonymity. Separate identities at the request level or through separate projects whose names and storage states are explicit.

Read the failure evidence before changing the assertion

A received 302 or 307 usually means the request crossed a page-oriented authentication boundary, proxy rule, or middleware redirect. Inspect the location header and the response URL. Do not change the expected value from 401 to 302 merely because the current stack redirects. Decide whether an API client can use that response. Most machine clients need an HTTP error with a JSON body, while a browser page may appropriately redirect.

A received 200 with text/html is often the same redirect after automatic following, a custom error page returned with the wrong status, or a request that hit a UI route instead of the route handler. The attached body will usually make that visible. Check the final response URL, route path, base URL, and proxy rules before touching authorization code.

A 404 in the anonymous case means the route or an upstream layer may be concealing everything, or the request never reached the intended handler. Confirm the endpoint exists in the deployed build. Add server-side request correlation that records a generated request ID, route name, and decision code without logging credentials. If the handler logged no matching decision, investigate routing and deployment.

A 401 in the viewer case means the credential was absent, expired, rejected, or encoded for a different environment. Do not “fix” the test by expecting 401 when its purpose is authorization. Prove the same token can reach a low-risk authenticated endpoint, or mint the token through the supported test setup. Never attach bearer tokens to the Playwright report.

A 403 in the authorized missing-ID case means the editor fixture lacks the permission the test assumes. That is fixture drift, not a missing-resource bug. Inspect the actor identity and permission names in protected server logs, then repair provisioning. Hard-coding a more privileged token into the repository creates a security problem and hides the real setup contract.

A 500 means the request crossed the planned client-error boundary. Read the server log tied to the request ID. Database connectivity, invalid configuration, an exception in authentication, or a malformed repository result can all produce it. Do not turn it into 404, since doing so would teach clients that infrastructure failures mean absence.

An unexpected 405 points to method wiring, not identity. Next.js Route Handlers support named HTTP method exports, and an unsupported method is rejected at that route boundary. Check whether the test used get, post, or patch as intended and whether the deployed route.ts exports that method. Changing the expected auth status would hide a test that never entered the handler branch under review.

Cache behavior is another near-miss for GET routes. If a proxy or application cache serves a previously stored denial to a different caller, the handler order can be correct while the observed identity is wrong. Inspect cache headers and repeat with a unique, non-sensitive request correlation value in a controlled environment. Protected error responses should follow the application’s explicit cache policy, and tests should not assume every GET is uncached merely because it contains authentication.

The APIResponse reference documents status(), headersArray(), text(), and json(). Pick one body-reading path in a diagnostic helper. Reading text first is useful because it preserves non-JSON evidence before parsing. Calling json() and then text() on copied fetch code can create confusion in other APIs where bodies are streams, even though Playwright stores response bodies for its context. One clear path is easier to review.

For a tenant-concealment policy, add a paired test. An existing project from another tenant and a truly missing ID must return the same status and public body to the tenant member. Do not assert only that both are “not ok.” Exact equality is the behavior that prevents a refactor from returning FOREIGN_PROJECT for one and PROJECT_NOT_FOUND for the other.

TypeScript
import { expect, test } from "@playwright/test";

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(name + " is required");
  }
  return value;
}

test("foreign and missing projects share the concealed response", async ({
  request,
}) => {
  const memberToken = requiredEnv("TENANT_MEMBER_TOKEN");
  const foreignId = requiredEnv("FOREIGN_PROJECT_ID");
  const missingId = requiredEnv("MISSING_PROJECT_ID");
  const options = {
    headers: { Authorization: "Bearer " + memberToken },
    failOnStatusCode: false,
    maxRedirects: 0,
  };

  const foreign = await request.get(
    "/api/tenant-projects/" + encodeURIComponent(foreignId),
    options,
  );
  const missing = await request.get(
    "/api/tenant-projects/" + encodeURIComponent(missingId),
    options,
  );

  expect(foreign.status()).toBe(404);
  expect(missing.status()).toBe(404);
  expect(await foreign.text()).toBe(await missing.text());
});

The environment helper is repeated here on purpose, because this block is a separate spec file. The earlier version declares requiredEnv as a module-local function, so nothing outside that file can call it. Copying the snippet as written into a second file produces ReferenceError: requiredEnv is not defined at run time and three instances of error TS2304: Cannot find name 'requiredEnv' under tsc --strict. In a real repository, move the helper into a small shared module and import it in both specs rather than duplicating it. Article snippets are a poor place to imply a module boundary that the reader cannot see, so each block here stands on its own.

This comparison has a real failure condition. If the application leaks a distinct message or status for the foreign resource, the test fails. The IDs must come from independent setup: one is known to belong to another tenant, and one is reserved as absent. Do not construct a fixture that labels a hard-coded ID “foreign” without actually provisioning and verifying its ownership.

Roll the contract into CI without making it brittle

Keep these tests in a small API project. They do not need a browser page, screenshots, or selectors. They do need an application server, deterministic identities, stable resource fixtures, and access to the response evidence. Running them before a large UI suite gives faster feedback when authentication middleware or route code changes.

The Playwright configuration below uses a supplied API_BASE_URL in CI and starts a local development server only when that variable is absent. It enables list and HTML reports, leaves retries at zero, and sets no global authentication. The exact local command should match the project’s package scripts.

TypeScript
import { defineConfig } from "@playwright/test";

const externalBaseUrl = process.env.API_BASE_URL;
const baseURL = externalBaseUrl ?? "http://127.0.0.1:3000";

export default defineConfig({
  testDir: "tests/api",
  fullyParallel: true,
  forbidOnly: Boolean(process.env.CI),
  retries: 0,
  reporter: [
    ["list"],
    ["html", { open: "never", outputFolder: "playwright-report" }],
  ],
  use: {
    baseURL,
  },
  webServer: externalBaseUrl
    ? undefined
    : {
        command: "pnpm dev",
        url: baseURL,
        reuseExistingServer: true,
        timeout: 120_000,
      },
});

Provision identities before the test process starts. Give each token the minimum stated permission and record which test environment issued it. Seed an existing resource for the authorized success path, a foreign resource for the concealment path, and a reserved absent ID. Remove mutable names and timestamps from exact assertions unless they are part of the contract.

Verify fixture independence before the suite runs. The existing ID must resolve for the authorized actor, the foreign ID must resolve for its owning tenant, and the missing ID must not resolve through an administrative setup interface. These are preconditions, not the status assertions themselves. Reporting a failed precondition separately prevents a deleted fixture from making every actor appear correctly denied.

Gate on the first run. A status test is not a good candidate for automatic retry because the expected result does not depend on animation or rendering. If a token service occasionally fails, classify and fix that setup boundary. Do not let a second token silently replace the identity whose first request failed.

Preserve the HTML report and server logs under a shared request ID, but keep access boundaries different. The test report can contain public response bodies. Authentication internals and database details belong in restricted server logs. A correlation ID lets the two audiences meet without copying secrets into a broadly visible artifact.

Roll out route by route. Start with one high-value protected endpoint and write the actor matrix. Extract the response helper only after two routes share the same body contract. A generic helper introduced too early often forces unlike policies into one shape and makes concealment decisions implicit.

The trade-off is test data and identity cost. Three actors and three resource states take more setup than one anonymous request. Exact bodies make accidental fields visible, but they also require an intentional update when the public contract evolves. That friction is useful at security boundaries. Keep display copy out of the machine object when product teams need to edit it frequently.

Know when 401, 403, and 404 are the wrong contract

Use 400 when the authenticated request is malformed and the client can correct its syntax or shape. Use 409 when a valid action conflicts with current resource state. Use 422 only when that meaning is part of the API’s declared contract. Use 429 for rate limiting. Do not compress every client-visible failure into the three statuses covered here.

Return a 5xx response when the server cannot complete a valid request because of its own failure. A database exception must not become 404 simply because the lookup produced no usable record. “No row returned” and “query did not complete” are different observations. Tests should inject or reproduce the failure at the repository boundary and assert the safe 5xx contract separately.

Do not expose 403 when the product has chosen resource concealment. The clearer status is not always the safer one. Conversely, do not conceal a simple role failure on an admin collection with 404 unless the policy requires it. Debuggability and client behavior have value, and concealment has a cost.

Skip end-to-end API tests for every branch already proven by a pure authorization function if deployment wiring cannot change the outcome. Keep a focused set at the route boundary, then cover permission combinations with fast unit tests. The route tests prove serialization, status, headers, middleware, and deployment. The lower-level tests carry the combinatorial load.

Avoid testing framework defaults you do not own. Next.js documents supported route methods and its current parameter contract, but your application chooses its auth helper, response body, and concealment policy. Assert those choices. If an unsupported method’s exact body is not a public promise, asserting it word for word couples the suite to framework implementation details.

Most importantly, never use “not 2xx” as the security oracle. It cannot tell a deliberate denial from a missing route, proxy failure, HTML redirect, expired fixture, or server crash. The strongest Next.js API 401 403 404 testing names the caller, preserves the endpoint and resource state, checks the exact public response, and proves the server stopped at the intended decision.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official nextjs.org reference

    nextjs.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

What is the practical difference between 401 and 403 in an API test?

Return 401 when the request lacks valid authentication for the protected resource. Use 403 when the server knows the caller's identity but that identity is not allowed to perform the action.

Should a protected resource return 403 or 404 to an unauthorized user?

Choose 404 when the product deliberately conceals whether the resource exists, and keep that response identical for missing and inaccessible IDs. Otherwise, 403 is clearer for an authenticated caller who lacks permission.

Can Playwright test Next.js route handlers without opening a browser?

Yes. The request fixture supplies an isolated APIRequestContext, and its methods return APIResponse objects for direct status, header, and body assertions.

What should I assert besides the 401, 403, or 404 status?

Check the content type, a stable machine-readable error code, the allowed response keys, and any required authentication challenge header. Also verify that redirects and HTML error pages cannot satisfy the API contract.

Why does the order of auth and database lookup matter?

Looking up a private record before authenticating can waste work and may create timing or logging differences that reveal whether it exists. Authenticate first, then apply the endpoint's authorization and concealment policy consistently.

Should failed API status tests be retried in CI?

A deterministic status mismatch should fail on its first attempt. Preserve that response and investigate the route, proxy, session, and fixture; a later pass does not make the earlier contract violation harmless.