Learn Build a Real SaaS with Claude Code Auth Security Review

Auth Security Review

Intermediate 🕐 24 min Lesson 22 of 25
What you'll learn
  • Explain why proxy.ts (Next.js 16's middleware rename) is a network boundary, not an authorization system
  • Identify why getUser() must be used over getSession() for any server-side auth check
  • Distinguish 401 Unauthorized from 403 Forbidden and apply each correctly in a route handler
  • Locate and verify the ownership join that is the real authorization boundary in a multi-tenant query
  • Recognize why the same ownership check needs to be repeated independently in both an API route and its equivalent Server Action
  • Apply a four-point checklist to audit auth on any new route before shipping it

From Shipped to Defensible

Lessons 15-21 took the reputation dashboard from working to polished: UX cleanup, a real test suite, a live deployment, and monitoring. The app runs. That is not the same question as whether the app is safe to run with real customer data in it. This lesson is a security review of the one mechanism that decides who sees what: authentication and authorization. The goal is not to add new code -- it is to walk every place an auth check could be missing, weak, or merely decorative, and confirm it is not.

Proxy.ts Is a Network Boundary, Not an Authorization System

The app's proxy.ts (Next.js 16's rename of middleware.ts -- the exported function is now proxy instead of middleware) is three lines that delegate to updateSession():

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

updateSession(), in lib/supabase/middleware.ts, does two jobs: refresh the Supabase session cookie on every request, and redirect signed-out users away from /dashboard:

const isProtectedRoute = request.nextUrl.pathname.startsWith("/dashboard");
if (isProtectedRoute && !user) {
  const url = request.nextUrl.clone();
  url.pathname = "/login";
  return NextResponse.redirect(url);
}

That redirect is a UX convenience -- it keeps a signed-out visitor from ever rendering a dashboard shell. It is not the app's real authorization boundary, and treating it as one is the single most common Next.js auth mistake. The rename from middleware to proxy in Next.js 16 exists specifically to signal this: it runs at the edge of the route graph, not inside your data logic, and nothing about it guarantees every code path that touches the database actually ran through it. A Server Action, a route handler called directly, or a future API consumer that does not go through the page tree at all would skip it entirely.

The real boundary is every individual place that reads or writes data, checking for itself.

getUser(), Never getSession(), on the Server

Both lib/auth.ts and the protected routes call supabase.auth.getUser(), not getSession():

export async function getCurrentUser() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  return user;
}

This matters more than it looks. getSession() reads the session straight out of a cookie, which is attacker-controlled input -- anyone can hand-craft a cookie and get a session object back without it ever being checked against Supabase. getUser() sends that token to the Supabase Auth server on every call to revalidate it. It is slightly slower and unambiguously the only one of the two that is safe to trust server-side. Every server-side auth check in this app -- lib/auth.ts, the API routes, the Server Actions -- goes through getCurrentUser(), so this one function is worth getting right once rather than auditing scattered calls.

Auditing the Real Boundaries: 401 vs 403

The review checks every route and action against two separate questions: is someone signed in (401), and does this signed-in person own this resource (403). Conflating them is the second most common mistake -- a route that only checks "is anyone logged in" lets any authenticated user touch any other tenant's data just by guessing an ID.

app/api/reviews/[id]/respond/route.ts gets both right, in order:

const user = await getCurrentUser();
if (!user) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ...
const [row] = await db
  .select({ reviewId: reviews.id, businessOwnerId: businesses.ownerId })
  .from(reviews)
  .innerJoin(businesses, eq(reviews.businessId, businesses.id))
  .where(eq(reviews.id, reviewId))
  .limit(1);

if (!row) {
  return NextResponse.json({ error: "Review not found" }, { status: 404 });
}
if (row.businessOwnerId !== user.id) {
  return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

That join from reviews to businesses on ownerId is the actual authorization boundary -- not the 401 check above it. Without it, any signed-in user could respond to any business's reviews by incrementing a UUID and hoping. The Server Action equivalent in app/dashboard/reviews/[id]/actions.ts (respondToReview, setReviewStatus) repeats the exact same join independently, because a Server Action is its own entry point and cannot assume the page that rendered its form already checked anything -- the form could be resubmitted, scripted, or hit with a stale reviewId after the page loaded.

app/api/reviews/route.ts takes a different but equally valid shape: rather than checking ownership per-row, it derives the business from getCurrentUser() first and scopes every query to eq(reviews.businessId, business.id) -- there is no path through that handler that can return another business's rows, because the WHERE clause makes it structurally impossible rather than relying on a per-row check to catch a mistake.

Defense in Depth, Not Defense in One Place

Layer What it actually does What it does NOT do
proxy.ts
Redirects signed-out visitors away from /dashboard pages; refreshes session cookies
Does not protect API routes, Server Actions, or anything called outside the page tree
getCurrentUser() in each route/action
Confirms a real, revalidated identity exists (401 if not)
Does not confirm that identity owns the specific resource requested
Ownership join/scope per query
Confirms the resource belongs to the caller (403/404 if not)
Does nothing if skipped -- it is not automatic, every new route has to add it

The pattern across this codebase is that all three layers exist independently and none of them assumes another one already ran. That repetition -- checking ownership in the API route and the Server Action that does the same job, rather than trusting one to cover the other -- is intentional, not duplication to clean up. If a future route handler skips the join, this is the actual question the review is trying to answer: would a signed-in user from Business A be able to read or write something belonging to Business B by changing a URL parameter? For every route in this app today, the answer is no -- but it is a question worth re-asking on every new route added after this lesson, not just the ones built so far.

What to Check When You Add a New Route

  • Does it call getCurrentUser() (or equivalent) and return 401 if there is no user -- before touching the database?
  • If it operates on a specific resource by ID, does the query itself scope to the current user's business, or does it fetch first and check ownership before any write?
  • Would copy-pasting another business's ID into the request return or modify that business's data?
  • Is the check duplicated in every entry point that can reach this logic (API route and Server Action, if both exist), rather than relying on proxy.ts to have already handled it?

With the auth boundary audited, the next lesson covers the other half of hardening a public-facing route: what happens when a caller is authenticated and authorized but sends too many requests, or sends well-formed garbage instead of a real review.

Key takeaways
  • proxy.ts only protects page navigation under /dashboard -- API routes and Server Actions need their own getCurrentUser() check, it is never inherited
  • getSession() trusts an attacker-controlled cookie; getUser() revalidates against the Supabase Auth server on every call and is the only one safe server-side
  • A 401 means no valid identity; a 403 means a valid identity that does not own the resource -- collapsing them into one check lets any signed-in user touch any tenant's data
  • The real authorization boundary in this app is the join from reviews to businesses on ownerId, repeated independently in every route and action that touches a review
  • Scoping a query's WHERE clause to the caller's own business.id (as in GET /api/reviews) is structurally safer than fetching first and checking ownership after
  • Every new route should be checked against: does it 401 with no user, does it scope or verify ownership before any read or write, and is that check duplicated in every entry point that reaches it