# Protecting route handlers is not the same as protecting pages

> A redirect is the right answer for a page and a terrible answer for fetch. Use 401 and 403 in route handlers, and never let the proxy be the only check.

A page guard and an API guard look like the same job. They are not, and using
the page pattern in a route handler produces one of the most confusing bugs in a
Next.js app: a `fetch` that returns `200 OK` with a body full of sign-in HTML.

## Why the page pattern breaks handlers

```ts
// route.ts: wrong
export async function POST() {
  const user = await requireUser();   // redirects when signed out
  // ...
}
```

`redirect()` throws a special error that Next turns into a 307. `fetch` follows
redirects by default, so the browser dutifully requests `/sign-in`, gets a 200
with an HTML page, and hands it to your `await response.json()`, which throws
`Unexpected token '<'`. Nothing in that error mentions authentication.

## Use the right helper

```ts
import { AuthError, authErrorResponse, requireApiRole } from "@/lib/auth/session";

export async function POST(request: Request) {
  try {
    const user = await requireApiRole("admin");
    const body = await request.json();
    return Response.json({ ok: true, actor: user.id });
  } catch (error) {
    const denied = authErrorResponse(error);
    if (denied) return denied;
    throw error;
  }
}
```

- **401**: no session. The client should send the user to sign in.
- **403**: there is a session, it lacks the role. Signing in again changes
  nothing; show "you do not have access".

Collapsing both into one status forces every client to guess. A 401 that really
meant 403 sends a signed-in user round a sign-in loop they cannot win.

## Pages redirect, and 404 for the wrong role

```tsx
export default async function InvoicesPage() {
  const user = await requireRole("admin", "/admin/invoices");
  return <Invoices />;
}
```

Anonymous → redirect to sign-in carrying a return path, so the user lands where
they were going. Signed-in-but-wrong-role → `notFound()`. A 403 page confirms
that `/admin/invoices` exists, which is free reconnaissance; a 404 says nothing.

## Server actions are handlers wearing a page's clothes

A server action is a POST endpoint with a generated id, callable by anyone who
can read your bundle. "It is only used on an admin page" is not a control:

```ts
"use server";
export async function deleteUser(id: string) {
  await requireRole("admin");    // first line, before any argument is used
  // ...
}
```

Actions can redirect (they run in a navigation context) so `requireRole` is
appropriate here. What matters is that the check exists and runs first.

## The proxy is not the boundary

`clerkMiddleware` runs before rendering and is the right place to bounce
signed-out visitors so they never see a flash of the shell. It is the wrong
place for the only check:

- It runs on a matcher, and the matcher can be edited by someone chasing an
  unrelated problem.
- It does not see every path that reaches a server action.
- A route added outside the matcher is silently unprotected, and nothing fails
  in a way anyone notices.

The rule of thumb: delete `proxy.ts` mentally and ask whether the endpoint is
still protected. If not, the check is in the wrong place.

`auth()` does need the proxy to have run, though. A route that throws "auth()
was called but Clerk can't detect usage of clerkMiddleware" is a route the
matcher does not cover: the fix is the matcher, not a try/catch.

## Webhooks are the exception, in both directions

`/api/webhooks/clerk` must be **public** (Clerk arrives with no session) and
must still authenticate, with a Svix signature over the raw body. Protecting it
with the proxy means no delivery ever arrives. Leaving it unverified means
anyone can write to your users table.

Every public route is public *for a reason you can name*. Write the reason in a
comment above it.

## What the client should do with each status

```ts
const response = await fetch("/api/admin/refunds", { method: "POST", body });

if (response.status === 401) {
  router.push(`/sign-in?next=${encodeURIComponent(pathname)}`);
  return;
}
if (response.status === 403) {
  setError("You do not have access to this action.");
  return;
}
if (!response.ok) {
  setError("Something went wrong. Try again.");
  return;
}
```

Three branches, one line each, and every failure mode becomes legible instead of
a JSON parse error.

## Checking your work

- `curl -i -X POST http://localhost:3000/api/your-route` with no cookie: 401
  and a JSON body. No HTML, no redirect.
- The same call with a signed-in but under-privileged cookie: 403.
- A protected page while signed out: 307 to `/sign-in?next=...`, and after
  signing in you land on the original path.
- A protected page with the wrong role: 404.
- Every route handler that mutates has a `requireApi*` call before it reads the
  request body.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
