Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 033 / 100

Protecting routes in the App Router: a step-by-step guide

Defense in depth beats one redirect.

Expona Admin9 min read

Protecting routes in the App Router: Defense in depth beats one redirect

Most teams I've worked with protect their routes in one place and call it secure. Then a client-side check gets bypassed, a token expires mid-navigation, or an API responds with 401 but the user sees cached content. Protecting routes in the App Router requires thinking in layers—each one independent, each one checking auth before serving anything sensitive.

The single redirect trap

Early in my career, I shipped protection that looked like this: check on the client, redirect if not authenticated. Ship fast, right? Until someone opened DevTools, removed the check, and cached the page. Or they stole a token and used it from a different IP. Or they waited for it to expire but the page still showed their data.

The pattern I see in most production apps is equally risky:

tsx
// ❌ Only client-side protection
'use client';

import { redirect } from 'next/navigation';
import { useSession } from '@/auth';

export default function DashboardLayout({ children }) {
  const { session } = useSession();
  
  if (!session) {
    redirect('/login');
  }
  
  return <>{children}</>;
}

This redirects eventually, but between page load and that redirect, your component renders. Data might leak to console logs. API calls might fire. The user sees a flash of content. More importantly, you're trusting client-side logic to protect server resources—and the server should never do that.

Optimistic redirect in proxy for UX

The first layer is pragmatic, not secure. A middleware in Next.js or a reverse proxy (Nginx, CloudFront) can intercept requests before they hit your app, check a session cookie, and redirect unauthenticated traffic immediately. This is purely for user experience.

In Next.js, middleware runs on the edge, before your app server:

tsx
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const sessionToken = request.cookies.get('session_token')?.value;
  
  if (!sessionToken && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
};

This saves bandwidth and gives instant feedback. Users without a valid session cookie never reach your app server. But cookies are not proof of authentication—they're a hint. A stolen cookie, an expired session, a forged value: none of these will be caught here. That's by design. This layer optimizes for the happy path.

Real-world example: at a ride-hailing platform I built, middleware redirected unauthenticated drivers to `/driver/login` and passengers to `/passenger/login`. Ninety-nine percent of protected traffic passed this check. It saved us thousands of dollars in compute every month by rejecting junk traffic at the edge.

Real check in layouts or the data layer

The second layer is authoritative. A server-side layout or a top-level data fetch function must validate the session before rendering or returning any data.

In Next.js App Router, layouts run on the server:

tsx
// app/dashboard/layout.tsx
import { getSession } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardLayout({ 
  children 
}: { 
  children: React.ReactNode 
}) {
  const session = await getSession();
  
  if (!session) {
    redirect('/login');
  }
  
  if (session.role !== 'user' && session.role !== 'admin') {
    redirect('/unauthorized');
  }
  
  return (
    <div className="dashboard">
      <header>Welcome, {session.user.name}</header>
      {children}
    </div>
  );
}

This layout runs before any child page. If there's no valid session, nothing below renders. No data fetches, no components, no console logs with sensitive info. The session is validated on the server before HTML is generated.

Validation must be stateless and cryptographic. I use JWT tokens with a strong secret, or I query the session store and check expiry, user status, and permissions. Never trust a session ID without verifying it server-side:

tsx
// auth.ts
import { jwtVerify } from 'jose';
import { cookies } from 'next/headers';

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function getSession() {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth_token')?.value;
  
  if (!token) {
    return null;
  }
  
  try {
    const verified = await jwtVerify(token, secret);
    
    // Optional: check token expiry, revocation list, etc.
    if (verified.payload.exp && verified.payload.exp * 1000 < Date.now()) {
      return null;
    }
    
    return verified.payload as SessionPayload;
  } catch (error) {
    return null;
  }
}

For e-commerce platforms, I've used session tables in PostgreSQL with a last-seen timestamp and device fingerprint. If a session is older than 24 hours or the fingerprint changes, I invalidate it server-side, regardless of the cookie. This prevents token reuse after a logout.

Server Actions check auth themselves

The third layer protects individual mutations. A Server Action that modifies data must verify the session independently. Never assume the layout checked it.

Why? Because a user can call a Server Action from anywhere—a mobile app, curl, an old page they bookmarked. The layout might not run. The route might be disabled in middleware. None of that matters if you're accepting server-to-server requests or if a client finds an old API route:

tsx
// app/dashboard/actions.ts
'use server';

import { getSession } from '@/auth';

export async function updateProfile(formData: FormData) {
  const session = await getSession();
  
  // Check auth first, before anything else
  if (!session) {
    throw new Error('Unauthorized');
  }
  
  // Check permission
  if (session.role !== 'user' && session.role !== 'admin') {
    throw new Error('Forbidden');
  }
  
  const userId = session.user.id;
  const name = formData.get('name') as string;
  
  // Update the database
  const result = await db
    .update(users)
    .set({ name })
    .where(eq(users.id, userId));
  
  return result;
}

Every Server Action I write starts with const session = await getSession(). If it returns null, the action fails. No shortcuts, no assumptions. This catches cases where someone reuses an old fetch request or finds an undocumented endpoint.

At a SaaS platform I built for CRM, we had an action to delete a contact. The layout checked auth, but we also checked it in the action. One day, a cron job called the action with an expired token it had cached. Because we checked in the action, it failed cleanly. Without that check, it would have silently deleted data.

Never trust client-side checks alone

This is the golden rule. Client-side checks are for UX and performance. They hide buttons, disable inputs, show spinners. But they are not security.

A user can:

  • Disable JavaScript
  • Modify the DOM
  • Intercept and replay network requests
  • Forge cookies or tokens in DevTools
  • Call your API directly from another app

None of this requires hacking skills. It's all built into the browser.

The pattern that breaks most often:

tsx
// ❌ Trust me, I checked on the client
'use client';

export function DeleteButton({ id }: { id: string }) {
  const [canDelete, setCanDelete] = useState(false);
  
  useEffect(() => {
    // Check if user can delete
    fetch('/api/check-permission')
      .then(res => res.json())
      .then(data => setCanDelete(data.canDelete));
  }, []);
  
  if (!canDelete) {
    return <span>No permission</span>;
  }
  
  // But if they delete it anyway...
  return <button onClick={() => fetch(`/api/items/${id}`, { method: 'DELETE' })}>
    Delete
  </button>;
}

If /api/items/[id] doesn't check the session, someone deletes the item by calling the endpoint directly. The client check was theater.

Always validate on the server. Always. Client checks are nice-to-haves.

Common mistakes

Checking auth in components instead of layouts

Components are client code by default. Even if you mark them async, if they import useContext or call hooks, they're still running client-side logic that can be bypassed. Layouts are server-side and run before children render. Use layouts for auth gates.

Validating the session once, then reusing it

Sessions expire. Permissions change. A user might be banned while they're using your app. Re-validate in every Server Action and every critical API route. Don't cache the session for longer than a few seconds.

Redirecting instead of throwing in Server Actions

If a Server Action detects no session, it should throw an error. Redirect works in layouts and pages, not in actions. Throwing lets the error boundary or error.tsx handle it cleanly:

tsx
// ✅ Correct
export async function deleteItem(id: string) {
  const session = await getSession();
  if (!session) throw new Error('Unauthorized');
  // ...
}

// ❌ Wrong
export async function deleteItem(id: string) {
  const session = await getSession();
  if (!session) redirect('/login'); // This might not work
}

Forgetting to check permissions, only checking authentication

Authenticated is not authorized. A logged-in user might not own the resource they're trying to modify. Always verify ownership or role:

tsx
export async function updatePost(postId: string, content: string) {
  const session = await getSession();
  if (!session) throw new Error('Unauthorized');
  
  // Check they own this post
  const post = await db
    .select()
    .from(posts)
    .where(eq(posts.id, postId))
    .then(res => res[0]);
  
  if (!post || post.userId !== session.user.id) {
    throw new Error('Forbidden');
  }
  
  // Now update
  await db.update(posts).set({ content }).where(eq(posts.id, postId));
}

Key takeaways

  • Use middleware for UX, not security. It redirects unauthenticated users fast, but it's the first line of defense, not the last.
  • Validate in server-side layouts before rendering protected pages. This catches most cases and keeps logic centralized.
  • Every Server Action checks the session independently. Don't assume the layout ran or the middleware passed.
  • Client-side checks are purely UX. They hide buttons and validate forms, but they don't protect data. Assume they'll be bypassed.
  • Distinguish between authentication (is this user logged in?) and authorization (do they own this resource?) Check both, always on the server.
  • Make auth checks stateless and fast. Use cryptographic tokens or session IDs you can verify in milliseconds, so they don't slow down every request.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles