AniUI Academy

Auth Boundaries in a Rendered App

Where auth checks actually happen in a modern server-rendered app — middleware, layout, Server Component, Server Action, API route — and why exactly one of those is the trustworthy one.

11 min read

A modern rendered app has five plausible places to check "is this user allowed to do this?":

  • Middleware (runs before the request reaches a route).
  • A root or layout component (runs on every page under it).
  • A Server Component that renders the specific page.
  • A Server Action that handles a mutation.
  • An API route handler.

Every one of them can check auth. The question — and the question that produces most real-world auth bugs — is which of them is the actual security boundary, and which are just defense in depth. This lesson is about the honest answer and the pattern that avoids the failure modes.

The rule, in one line

The security boundary is the thing that touches the data.

Middleware is a convenience. A layout check is a convenience. A UI guard is a convenience. The one check that has to be right is the one on the code path that reads from or writes to the database. Everything else is pre-filtering — useful for ergonomics and performance, worthless as the last line of defense if the underlying handler trusts the request.

The reason this rule is load-bearing: an attacker doesn't go through your UI. They go through the HTTP surface, and the HTTP surface is reachable from anywhere. If the layout and middleware checks live upstream of a handler, the handler is only safe if nothing can reach it without passing through them. In practice, that condition quietly breaks as the app grows.

Why upstream-only checks fail

Three concrete ways the upstream-check-only pattern breaks:

  1. Step 1

    Matcher coverage drift

    Middleware runs on a configured set of paths (`config.matcher`). A new route lands outside the matcher — an API route, a public page that should have been private, a webhook endpoint — and the middleware silently doesn't fire. The new code assumes it was covered.

  2. Step 2

    Programmatic clients ignore redirects

    Middleware blocks the browser by returning a 302 redirect to /sign-in. A programmatic client following `fetch` defaults *follows* the redirect automatically and parses the sign-in page as the API response. Another client disables redirects and sees the 302 as success. Neither client is actually blocked from hitting the handler.

  3. Step 3

    Layout checks don't cover siblings

    A layout under /app checks auth. A new route /admin is added without realising it's under a different layout — or under no layout. The check never runs for /admin.

None of these are theoretical. All three show up in post-mortems regularly.

The design that actually works

Every piece of code that touches data does its own auth check, every time. The shape:

// In a Server Component
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation";
 
export default async function ProfilePage({ params }: { params: { id: string } }) {
  const session = await auth();
  if (!session) redirect("/sign-in");
 
  const profile = await db.profile.findUnique({ where: { id: params.id } });
  if (!profile) notFound();
 
  if (profile.visibility === "private" && profile.ownerId !== session.user.id) {
    notFound(); // not 'forbidden' — don't leak existence
  }
 
  return <Profile data={profile} />;
}

Three load-bearing lines:

  • auth() reads the session. The trustworthy identity lives here, not in props or URL params.
  • The authorization check is against the actual data and the current session's user, not against what the caller claimed.
  • The failure mode is notFound() for private resources, not "403 Forbidden." Returning 403 for a resource that exists tells an attacker it exists. Returning 404 for anything they're not allowed to see conflates "doesn't exist" with "exists but hidden," which is almost always the right trade.

Server Actions: RPC endpoints, not internal helpers

Server Actions feel like local function calls. They're not. Every Server Action is exposed over HTTP; anyone who can reach the server can call it with any arguments, from anywhere, not just through the component that imports it.

Which means the right shape for a Server Action is the same as the right shape for an API route — the convenience of the calling syntax doesn't change the security surface:

"use server";
 
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
 
export async function deletePost(postId: string) {
  const session = await auth();
  if (!session) throw new Error("UNAUTHENTICATED");
 
  const post = await db.post.findUnique({ where: { id: postId } });
  if (!post) throw new Error("NOT_FOUND");
 
  if (post.authorId !== session.user.id && !session.user.isAdmin) {
    throw new Error("FORBIDDEN");
  }
 
  await db.post.delete({ where: { id: postId } });
  revalidatePath("/posts");
}

Every one of those checks runs every time the action runs. "The UI only shows the delete button to the owner" is a UX consideration, not a security one. A user with a browser console can call deletePost("some-other-user-post-id") directly against any page that has imported it.

API routes: same rule

An API route handler's shape should look identical:

// app/api/posts/[id]/route.ts
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
 
export async function DELETE(
  _req: Request,
  { params }: { params: { id: string } },
) {
  const session = await auth();
  if (!session) return new NextResponse("Unauthorized", { status: 401 });
 
  const post = await db.post.findUnique({ where: { id: params.id } });
  if (!post) return new NextResponse("Not Found", { status: 404 });
 
  if (post.authorId !== session.user.id && !session.user.isAdmin) {
    return new NextResponse("Not Found", { status: 404 });
  }
 
  await db.post.delete({ where: { id: params.id } });
  return new NextResponse(null, { status: 204 });
}

Same three checks. Same answer for "forbidden": 404, not 403, for the user-facing surface.

Where middleware and layouts are still useful

The rule above makes it sound like upstream checks are useless. They're not — they're just not the security layer. They're the ergonomics layer:

  • Middleware to redirect unauthenticated users to sign-in. Saves every page from having to write if (!session) redirect(...). The downstream pages still check — but the check becomes a no-op when middleware did its job, and the redirect to a nice sign-in page is a UX win.
  • Layout-level session fetching. The layout fetches the session once and passes it down (or caches it per-request); the pages beneath don't each pay the cost of a separate fetch.
  • UI-level visibility. Hiding a delete button a user isn't allowed to use is right for UX. It's still not the security check — that lives on the handler.

The design question to ask: "if this upstream check didn't run, would data still be protected?" If yes, the upstream check is real defense in depth. If no, the upstream check is the check, and you've put your security on a quietly fragile layer.

The pattern that scales: a requireUser helper

Most real codebases converge on a small helper:

// lib/auth/require.ts
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export async function requireUser() {
  const session = await auth();
  if (!session) redirect("/sign-in");
  return session.user;
}
 
export async function requireAdmin() {
  const user = await requireUser();
  if (!user.isAdmin) notFound(); // 404 for admin-only surfaces
  return user;
}

The pattern: every route-touching code path starts with one of these. The helper is cheap enough to call everywhere; the shared implementation is testable; the failure mode is obvious (a missing requireUser call is a code review grep away). This is the tooling equivalent of "the security boundary is the thing that touches the data" — make the check the first line of every such thing.

What to remember

  • The security boundary is the thing that touches the data: the Server Component, Server Action, or API route handler. Middleware, layouts and UI-level checks are pre-filters, not the boundary.
  • Upstream-only checks fail because of matcher drift, programmatic clients ignoring redirects, and layouts not covering sibling routes. All three are real failure modes.
  • Trust the session, not the arguments. Props and URL params are what the caller claimed; the session is who the actual user is.
  • Server Actions are RPC endpoints over HTTP, reachable from anywhere. They need their own auth check every time, same as an API route.
  • Return 404 (not 403) for private resources the user can't see. 403 leaks existence; 404 conflates "doesn't exist" with "not allowed."
  • The pattern that scales: a requireUser / requireAdmin helper called at the top of every route-touching function. Makes the security boundary a code-review-visible line.
  • Upstream checks are fine for UX (redirect to sign-in, avoid duplicate session fetches, hide buttons). The design question is "if this check didn't fire, would data still be safe?" If no, the check isn't where it belongs.

Check yourself

4 questions · pass 3/4 to unlock Local, Lifted, and Global State

up to 50
  1. 1.Of the possible places to check 'is this user allowed to see this data' — middleware, layout, Server Component, Server Action, API route — which is the *only* truly trustworthy boundary, and why?

  2. 2.A Next.js middleware.ts redirects unauthenticated requests to /sign-in. A developer adds a new API route /api/admin/users and assumes the middleware covers it. What's the realistic failure mode?

  3. 3.A Server Component reads const user = await db.user.findUnique({ where: { id: props.userId } }) and renders the user's profile. What's wrong with trusting props.userId for the auth decision?

  4. 4.A Server Action writes to the database. Should it re-check auth, or can it rely on 'if you got here, you must be allowed'?

4 left to answer

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.