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

Authentication in Next.js for React & Next.js developers

Patterns that work with Server Components.

Expona Admin8 min read

Authentication in Next.js: Patterns That Work with Server Components

After five years building production platforms, I've learned that authentication in Next.js is only as secure as the layer where you check it. The gap between "the user is logged in" and "the user can access this data" is where most breaches happen. Today, we're building auth patterns that actually work with Server Components—because your middleware and your pages both need to know the truth.

Why Server-First Auth Matters

The old pattern was simple: store the token in localStorage, check it on every page load, and guard routes with client-side logic. Next.js Server Components flip this. Your data fetches run on the server before the page renders. If you're still thinking "check auth in middleware," you're halfway there. You also need to verify the session at the exact moment you access data.

This isn't paranoia. It's because middleware runs once per request, but your data layer might run multiple times—from different pages, in concurrent fetches, or after permissions change. The server doesn't cache whether the user is "allowed." It caches the data they requested.

Sessions in httpOnly Cookies — Server Reads, JavaScript Can't Touch

Start with httpOnly cookies. They live in HTTP headers, not document.cookie. JavaScript can't touch them. Your server can read them. This is the foundation.

When a user logs in, you set a cookie like this:

tsx
// app/api/login/route.ts
import { cookies } from 'next/headers';
import crypto from 'crypto';

export async function POST(req: Request) {
  const { email, password } = await req.json();

  // Verify credentials against your DB
  const user = await db.user.findUnique({ where: { email } });
  if (!user || !verifyPassword(password, user.passwordHash)) {
    return Response.json({ error: 'Invalid' }, { status: 401 });
  }

  // Create session
  const sessionToken = crypto.randomBytes(32).toString('hex');
  await db.session.create({
    data: { token: sessionToken, userId: user.id, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
  });

  // Set httpOnly cookie
  const cookieStore = await cookies();
  cookieStore.set('sessionToken', sessionToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60,
  });

  return Response.json({ success: true });
}

Now, in any Server Component or API route, you read that cookie:

tsx
// app/lib/auth.ts
import { cookies } from 'next/headers';

export async function getSessionUser() {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('sessionToken')?.value;

  if (!sessionToken) {
    return null;
  }

  const session = await db.session.findUnique({
    where: { token: sessionToken },
    include: { user: true },
  });

  if (!session || session.expiresAt < new Date()) {
    return null;
  }

  return session.user;
}

The browser sends the cookie automatically on every request. The server parses it. No JavaScript involved. The frontend never sees the token.

Check Auth Where Data Is Fetched — The Data Access Layer, Not Only Middleware

Middleware is useful for redirects and logging, but it's not where you protect data. Middleware runs per request. Your data fetch might call five different database queries, each with different permission rules. Middleware can't know that.

Instead, check auth in your data access layer. This is the module that talks to your database and knows the user's id. Before returning data, verify the session and the user's permissions.

Let's say you have a CRM. Users can see their own contacts, and team members can see shared contacts:

tsx
// app/lib/data.ts
import { getSessionUser } from './auth';

export async function getUserContacts(contactId: string) {
  const user = await getSessionUser();
  if (!user) {
    throw new Error('Unauthorized');
  }

  const contact = await db.contact.findUnique({ where: { id: contactId } });

  if (!contact) {
    throw new Error('Not found');
  }

  // Check: Does this user own it, or is it shared with their team?
  const isOwner = contact.ownerId === user.id;
  const isShared = await db.contactShare.findFirst({
    where: { contactId, teamId: user.teamId },
  });

  if (!isOwner && !isShared) {
    throw new Error('Forbidden');
  }

  return contact;
}

Your Server Component calls this:

tsx
// app/contacts/[id]/page.tsx
import { getUserContacts } from '@/app/lib/data';

export default async function ContactPage({ params }: { params: { id: string } }) {
  try {
    const contact = await getUserContacts(params.id);
    return <div>{contact.name} — {contact.email}</div>;
  } catch (error) {
    return <div>Access denied</div>;
  }
}

Why does this matter? Because if you only check auth in middleware, and someone calls your API route directly, or if permissions change mid-request, unprotected data leaks. The data layer is always the truth.

Auth.js or Your Own — Trade-Offs

Auth.js (NextAuth.js v5+) handles OAuth providers, session refresh, CSRF tokens, and callbacks. It's fast if you're integrating Google, GitHub, or other providers. It abstracts away most of the complexity.

But if your backend already issues JWTs, or you have a custom authentication system, rolling your own like we did above is simpler. You avoid middleware callbacks, a new database table, and learning Auth.js's session interface. You keep one source of truth: your backend.

If you choose Auth.js:

tsx
// app/auth.ts (with Auth.js)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

export const { auth, handlers, signIn, signOut } = NextAuth({
  providers: [GitHub],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.id = token.id;
      return session;
    },
  },
});

Then in your data layer:

tsx
import { auth } from '@/app/auth';

export async function getUserContacts(contactId: string) {
  const session = await auth();
  if (!session?.user?.id) {
    throw new Error('Unauthorized');
  }
  // ... rest of permission check
}

Use Auth.js if you need OAuth and want proven defaults. Use your own if you have a backend session system and want to keep the stack smaller.

A Data Access Layer — One Module That Checks Permissions Before Returning Data

This is the core pattern. Create one file (or module) that every data fetch goes through. It's your permission gateway.

tsx
// app/lib/data.ts
import { getSessionUser } from './auth';

export const data = {
  contacts: {
    get: async (id: string) => {
      const user = await getSessionUser();
      if (!user) throw new Error('Unauthorized');

      const contact = await db.contact.findUnique({ where: { id } });
      if (!contact) throw new Error('Not found');

      if (contact.ownerId !== user.id && !await isSharedWithTeam(contact.id, user.teamId)) {
        throw new Error('Forbidden');
      }

      return contact;
    },
    list: async (filter?: any) => {
      const user = await getSessionUser();
      if (!user) throw new Error('Unauthorized');

      return db.contact.findMany({
        where: {
          OR: [
            { ownerId: user.id },
            { shares: { some: { teamId: user.teamId } } },
          ],
          ...filter,
        },
      });
    },
    create: async (payload: any) => {
      const user = await getSessionUser();
      if (!user) throw new Error('Unauthorized');

      return db.contact.create({
        data: { ...payload, ownerId: user.id },
      });
    },
  },
};

Now every component calls through this layer:

tsx
import { data } from '@/app/lib/data';

export default async function Dashboard() {
  const contacts = await data.contacts.list();
  return <ul>{contacts.map(c => <li>{c.name}</li>)}</ul>;
}

Benefits: one place to audit, one place to log, one place to patch a permission bug. If your backend roles change, you update `data.ts`. Tests focus here. Security reviews start here.

Common Mistakes

  • Checking auth only in middleware: Middleware doesn't run on Server Component data fetches. You'll leak data. Always check in the data layer.
  • Storing tokens in localStorage: If you're building with Server Components, localStorage is often unnecessary. Use httpOnly cookies for sessions, period.
  • Trusting the client-side session object: Never trust request.headers.get('user-id') sent by the client. Always verify the session from the cookie or a signed token.
  • Forgetting to check permissions on read, not just write: Developers protect POST and DELETE but forget GET. A contact list that returns all contacts if auth is missing is a breach.
  • Mixing auth checks across files: If five pages have their own permission logic, one will be wrong. Centralize it.

Key Takeaways

  • Use httpOnly cookies to store sessions. The browser sends them automatically; JavaScript can't steal them.
  • Check authentication in your data access layer, not only middleware. Middleware handles redirects; the data layer protects data.
  • Choose Auth.js for OAuth providers and proven defaults, or build your own if your backend already handles sessions.
  • Centralize all data access through one module. Permission logic lives in one place, making audits and fixes easier.
  • Verify the session and user permissions before every data fetch, even in Server Components that seem "internal."
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