Module 10: Authentication & Authorization with Auth.js v5 and Next.js 16 proxy.ts

Module 10: Authentication & Authorization with Auth.js v5 and Next.js 16 proxy.ts

AI Reading

Quick summary of this article

Add secure authentication to Next.js 16 with Auth.js v5: OAuth and credentials providers, session handling, route protection in proxy.ts, and role-based authorisation enforced server-side.

  • Next.js 16 renames middleware.ts to proxy.ts — it targets the Node.js runtime and Auth.js wraps it cleanly.
  • Auth.js v5 exports handlers, auth, signIn and signOut from one auth.ts file.
  • proxy.ts is a convenience layer for redirects — every Server Action and Route Handler must still authorise independently.
  • Role checks belong in the data layer, not in the UI, because hidden buttons are not access control.

Complete Next.js 16 Series — Module 10 of 12

Authentication is the part of a Next.js app that is most often subtly wrong. This module builds it correctly: one auth config, sessions on the server, protection in proxy.ts, and authorisation checks where the data actually lives.

Installing Auth.js

npm install next-auth@beta
npx auth secret

next-auth@beta is Auth.js v5, the version designed for the App Router — v4 has no Server Component support. npx auth secret writes AUTH_SECRET into .env.local; generate a separate one for production.

Status note: Auth.js v5 is still published under the beta tag. It is widely used in production, but pin the exact version in package.json so an upgrade cannot surprise you.

One Config File

src/auth.ts:

import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";

export const { handlers, auth, signIn, signOut } = NextAuth({
  session: { strategy: "jwt" },
  pages: { signIn: "/login" },
  providers: [
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
    Credentials({
      credentials: { email: {}, password: {} },
      async authorize(raw) {
        const parsed = z
          .object({ email: z.email(), password: z.string().min(8) })
          .safeParse(raw);

        if (!parsed.success) return null;

        const user = await db.user.findUnique({ where: { email: parsed.data.email } });
        if (!user?.passwordHash) return null;

        const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
        if (!valid) return null;

        return { id: user.id, email: user.email, name: user.name, role: user.role };
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = (user as { role?: string }).role ?? "USER";
        token.uid = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.uid as string;
        session.user.role = token.role as "USER" | "EDITOR" | "ADMIN";
      }
      return session;
    },
  },
});

Add the type augmentation so session.user.role is typed everywhere — src/types/next-auth.d.ts:

import type { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user: { id: string; role: "USER" | "EDITOR" | "ADMIN" } & DefaultSession["user"];
  }
}

The Route Handler

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;

That is the entire auth endpoint: callback URLs, CSRF protection, session reads, sign-in and sign-out.

Reading the Session on the Server

In a Server Component or Server Action:

import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function AccountPage() {
  const session = await auth();
  if (!session?.user) redirect("/login?callbackUrl=/account");

  return (
    <div className="mx-auto max-w-lg px-6 py-12">
      <h1 className="text-2xl font-bold">Welcome, {session.user.name}</h1>
      <p className="text-sm text-neutral-500">Role: {session.user.role}</p>
    </div>
  );
}

auth() reads the session cookie on the server. No client-side provider, no useSession fetch, no loading spinner — the HTML arrives already personalised.

Protecting Routes with proxy.ts

In Next.js 16, middleware.ts is deprecated and renamed to proxy.ts. The runtime is Node.js and is not configurable.

// src/proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  const { pathname } = req.nextUrl;
  const isLoggedIn = !!req.auth;
  const role = req.auth?.user?.role;

  // Only /admin/* requires ADMIN; /dashboard/* requires any session
  if (pathname.startsWith("/admin") && role !== "ADMIN") {
    return NextResponse.redirect(new URL("/403", req.nextUrl));
  }

  if (pathname.startsWith("/dashboard") && !isLoggedIn) {
    const login = new URL("/login", req.nextUrl);
    login.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(login);
  }

  return NextResponse.next();
});

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

The matcher keeps the proxy off every static asset and public page — running it globally is the number one performance mistake with middleware-style files.

proxy.ts is a routing convenience, not a security boundary. It can only redirect. A Server Action invoked directly over HTTP never passes through it. Authorise again inside every action.

Signing In and Out

Client-side buttons:

"use client";

import { signIn, signOut } from "next-auth/react";

export function SignInButton() {
  return (
    <button
      onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
      className="rounded bg-neutral-900 px-4 py-2 text-white"
    >
      Continue with GitHub
    </button>
  );
}

export function SignOutButton() {
  return (
    <button onClick={() => signOut({ callbackUrl: "/" })} className="text-sm text-neutral-600">
      Sign out
    </button>
  );
}

Or from a Server Action, which is better for progressive enhancement:

"use server";

import { signIn, signOut } from "@/auth";

export async function signInWithGitHub() {
  await signIn("github", { redirectTo: "/dashboard" });
}

export async function signOutAction() {
  await signOut({ redirectTo: "/" });
}

Authorisation: The Part People Get Wrong

Hiding an admin link is not authorisation. Anyone can POST to your Server Action directly. Enforce access at three layers:

// src/lib/authz.ts
import "server-only";
import { auth } from "@/auth";

const RANK = { USER: 1, EDITOR: 2, ADMIN: 3 } as const;
export type Role = keyof typeof RANK;

export async function requireUser() {
  const session = await auth();
  if (!session?.user) throw new Error("UNAUTHENTICATED");
  return session.user;
}

export async function requireRole(minimum: Role) {
  const user = await requireUser();
  if (RANK[user.role] < RANK[minimum]) throw new Error("FORBIDDEN");
  return user;
}

Then in every mutating action:

"use server";

import { requireRole } from "@/lib/authz";

export async function deletePost(id: string) {
  const user = await requireRole("EDITOR");
  const post = await db.post.findUnique({ where: { id } });
  if (!post) throw new Error("NOT_FOUND");

  // Editors may only delete their own posts; admins may delete any.
  if (user.role !== "ADMIN" && post.authorId !== user.id) {
    throw new Error("FORBIDDEN");
  }

  await db.post.delete({ where: { id } });
  revalidateTag("posts", "max");
}

That ownership check is the difference between a demo and a product. Never trust an ID from the client without verifying who owns it.

Securing Cookies and Secrets

AUTH_SECRET="<openssl rand -base64 32>"
AUTH_URL="https://yourdomain.com"
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
  • AUTH_SECRET signs the session JWT — rotate it and every session is invalidated.
  • AUTH_URL (or AUTH_TRUST_HOST=true behind a proxy) prevents callback mismatches.
  • Session cookies are httpOnly, secure and sameSite=lax by default in Auth.js v5.

Store password hashes with bcrypt (cost 12) or argon2id. Never store or log plaintext passwords, and never return a “user not found” message that distinguishes an unknown email from a wrong password.

Module 10 Recap

  • Auth.js v5 (next-auth@beta) is the App Router-ready version; pin it.
  • auth.ts exports handlers, auth, signIn, signOut; the catch-all route re-exports handlers.
  • auth() in Server Components gives the session with no client fetch.
  • proxy.ts replaces middleware.ts in Next.js 16, runs on Node.js, and needs a tight matcher.
  • Authorise in every Server Action and Route Handler — the proxy cannot protect them.
  • Centralise checks in requireUser() / requireRole() and always verify resource ownership.

Next up — Module 11: performance — images, fonts, streaming, bundle analysis and Cache Components.

Leave a Reply

Your email address will not be published. Required fields are marked *