Inleiding: Waarom Authenticatie in Next.js Opnieuw Uitvinden?
Laten we eerlijk zijn: authenticatie is niet het meest sexy onderdeel van webontwikkeling, maar het is wél het fundament van vrijwel elke moderne webapplicatie. Of je nu een SaaS-platform, een e-commerce site of een interne bedrijfstool bouwt — zonder een betrouwbaar authenticatiesysteem kom je simpelweg niet ver. En met de komst van de Next.js App Router en React Server Components is de manier waarop we authenticatie implementeren behoorlijk veranderd.
Auth.js v5 (de opvolger van NextAuth.js) is volledig herschreven om naadloos samen te werken met de App Router. Waar je vroeger sessies via getServerSession() moest ophalen en complexe API-routes moest configureren, biedt Auth.js v5 nu een elegante, universele auth()-functie die overal in je applicatie werkt — van Server Components tot middleware, van Route Handlers tot Server Actions. Eerlijk gezegd voelt het alsof ze eindelijk hebben geluisterd naar wat developers nodig hadden.
In dit artikel bouwen we stap voor stap een compleet authenticatiesysteem. We beginnen bij de installatie, configureren OAuth-providers zoals GitHub en Google, implementeren credentials-based login met wachtwoord-hashing, beveiligen routes met middleware, en bouwen uiteindelijk een volwaardig rolgebaseerd toegangsbeheer (RBAC). Aan het einde heb je een productie-klaar authenticatiepatroon dat je direct kunt toepassen in je eigen projecten.
Dus, laten we erin duiken.
Auth.js v5 Installatie en Basisconfiguratie
Pakketten Installeren
De eerste stap is het installeren van Auth.js v5 samen met de benodigde adapters. Let op: Auth.js gebruikt nu het @auth/*-scope voor database-adapters in plaats van het oude @next-auth/*-scope.
npm install next-auth@latest @auth/prisma-adapter
npm install prisma @prisma/client bcryptjs
npm install -D @types/bcryptjs
Je hebt Prisma nodig als database-adapter (hoewel Auth.js ook Drizzle, TypeORM en andere adapters ondersteunt), en bcryptjs voor het hashen van wachtwoorden bij credentials-login.
Omgevingsvariabelen Configureren
Auth.js v5 kan automatisch omgevingsvariabelen herkennen die beginnen met AUTH_. Dat scheelt een hoop handmatige configuratie. Maak een .env.local-bestand aan in de root van je project:
# Auth.js configuratie
AUTH_SECRET=jouw-geheime-sleutel-genereer-met-openssl-rand-base64-32
# GitHub OAuth
AUTH_GITHUB_ID=jouw-github-client-id
AUTH_GITHUB_SECRET=jouw-github-client-secret
# Google OAuth
AUTH_GOOGLE_ID=jouw-google-client-id
AUTH_GOOGLE_SECRET=jouw-google-client-secret
# Database
DATABASE_URL="postgresql://user:wachtwoord@localhost:5432/mijn-app"
Genereer een veilig geheim met het commando npx auth secret of openssl rand -base64 32. Dit geheim wordt gebruikt voor het versleutelen van JWT-tokens en sessie-cookies. Sla dit echt veilig op — als iemand dit te pakken krijgt, kan die je sessies manipuleren.
Prisma Schema voor Auth.js
Auth.js vereist specifieke tabellen in je database. Definieer het Prisma-schema met de benodigde modellen voor gebruikers, accounts, sessies en verificatietokens:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
role String @default("user")
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
Let op het role-veld in het User-model — dat hebben we later nodig voor rolgebaseerde toegangscontrole. Het password-veld is optioneel omdat OAuth-gebruikers geen wachtwoord nodig hebben.
Voer de migratie uit:
npx prisma migrate dev --name init
npx prisma generate
Prisma Client Singleton
Om te voorkomen dat er tijdens ontwikkeling meerdere Prisma Client-instanties worden aangemaakt door hot module replacement (een klassieker waar iedereen tegenaan loopt), gebruik je een singleton-patroon:
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
De Auth.js Configuratie Opzetten
Het Configuratiebestand
In Auth.js v5 centraliseer je de volledige configuratie in een enkel bestand. De kern draait om het exporteren van auth, signIn, signOut en handlers vanuit een gecentraliseerd configuratiebestand:
// auth.config.ts
import type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email("Ongeldig e-mailadres"),
password: z.string().min(8, "Wachtwoord moet minimaal 8 tekens bevatten"),
});
export default {
providers: [
GitHub,
Google,
Credentials({
name: "Inloggen met e-mail",
credentials: {
email: { label: "E-mailadres", type: "email" },
password: { label: "Wachtwoord", type: "password" },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({
where: { email },
});
if (!user || !user.password) return null;
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) return null;
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
};
},
}),
],
pages: {
signIn: "/inloggen",
error: "/auth/fout",
},
} satisfies NextAuthConfig;
Dit configuratiebestand bevat alle provider-definities en aangepaste pagina-routes. De Credentials-provider valideert de invoer met Zod — hetzelfde validatiepatroon dat je wellicht al kent uit het werken met Server Actions.
Het Hoofdconfiguratiebestand
Het eigenlijke auth.ts-bestand importeert de configuratie en voegt de database-adapter en callbacks toe:
// auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import authConfig from "@/auth.config";
export const { auth, handlers, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig,
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
});
Er zijn twee belangrijke dingen om op te merken hier. Ten eerste gebruiken we de jwt-sessiestrategie in plaats van database. Dit is nodig omdat de Credentials-provider alleen werkt met JWT-sessies. Ten tweede breiden we het JWT-token en de sessie uit met het role-veld — essentieel voor de RBAC die we later gaan bouwen.
TypeScript Type-Uitbreiding
Om TypeScript op de hoogte te brengen van het extra role-veld, moet je de types uitbreiden. Dit is een van die dingen die makkelijk vergeten wordt, maar zonder dit krijg je overal TypeScript-fouten:
// types/next-auth.d.ts
import { DefaultSession, DefaultUser } from "next-auth";
import { DefaultJWT } from "next-auth/jwt";
declare module "next-auth" {
interface User extends DefaultUser {
role?: string;
}
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
role?: string;
id?: string;
}
}
API Route Handler
De API-route voor Auth.js is drastisch vereenvoudigd ten opzichte van eerdere versies. Je hebt slechts één bestand nodig:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Dat is alles. Twee regels code. Auth.js v5 regelt intern alle benodigde routes voor inloggen, uitloggen, callbacks en sessiebeheer. Als je terugdenkt aan de eerdere versies met al die configuratie, is dit echt een verademing.
OAuth-Providers: GitHub en Google Integreren
GitHub OAuth Configureren
Om GitHub als OAuth-provider te gebruiken, moet je eerst een OAuth-applicatie aanmaken op GitHub:
- Ga naar GitHub > Settings > Developer settings > OAuth Apps
- Klik op "New OAuth App"
- Stel de Homepage URL in op
http://localhost:3000 - Stel de Authorization callback URL in op
http://localhost:3000/api/auth/callback/github - Kopieer het Client ID en Client Secret naar je
.env.local
Auth.js v5 herkent automatisch de variabelen AUTH_GITHUB_ID en AUTH_GITHUB_SECRET, waardoor je verder niks hoeft te doen in de provider-definitie. Best handig.
Google OAuth Configureren
Voor Google OAuth volg je een vergelijkbaar proces via de Google Cloud Console:
- Maak een project aan in de Google Cloud Console
- Ga naar APIs & Services > Credentials
- Maak OAuth 2.0 Client ID-credentials aan
- Voeg
http://localhost:3000/api/auth/callback/googletoe als Authorized redirect URI - Kopieer de credentials naar je
.env.local
Inlogknoppen Bouwen met Server Actions
Met Auth.js v5 kun je de signIn-functie direct gebruiken als Server Action in een formulier. Dit is eerlijk gezegd een van de krachtigste patronen van de nieuwe versie:
// app/inloggen/page.tsx
import { signIn } from "@/auth";
export default function InlogPagina() {
return (
<div className="mx-auto max-w-md space-y-6 p-8">
<h1 className="text-2xl font-bold">Inloggen</h1>
{/* GitHub OAuth */}
<form
action={async () => {
"use server";
await signIn("github", { redirectTo: "/dashboard" });
}}
>
<button
type="submit"
className="w-full rounded bg-gray-900 px-4 py-2 text-white"
>
Inloggen met GitHub
</button>
</form>
{/* Google OAuth */}
<form
action={async () => {
"use server";
await signIn("google", { redirectTo: "/dashboard" });
}}
>
<button
type="submit"
className="w-full rounded bg-blue-600 px-4 py-2 text-white"
>
Inloggen met Google
</button>
</form>
</div>
);
}
Merk op dat de signIn-functie hier wordt gebruikt als een server-side actie. De "use server"-directive maakt het mogelijk om deze code veilig op de server uit te voeren, zonder dat gevoelige informatie naar de client lekt.
Credentials-Login met E-mail en Wachtwoord
Registratieformulier met Server Action
Voordat gebruikers kunnen inloggen met credentials, moeten ze zich natuurlijk eerst registreren. Hier is een registratie Server Action die het wachtwoord veilig hasht:
// app/acties/registreren.ts
"use server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
const registratieSchema = z.object({
naam: z.string().min(2, "Naam moet minimaal 2 tekens bevatten"),
email: z.string().email("Ongeldig e-mailadres"),
wachtwoord: z
.string()
.min(8, "Wachtwoord moet minimaal 8 tekens bevatten")
.regex(/[A-Z]/, "Wachtwoord moet een hoofdletter bevatten")
.regex(/[0-9]/, "Wachtwoord moet een cijfer bevatten"),
});
export async function registreren(
prevState: { error?: string; success?: boolean },
formData: FormData
) {
const parsed = registratieSchema.safeParse({
naam: formData.get("naam"),
email: formData.get("email"),
wachtwoord: formData.get("wachtwoord"),
});
if (!parsed.success) {
return { error: parsed.error.errors[0].message };
}
const { naam, email, wachtwoord } = parsed.data;
const bestaandeGebruiker = await prisma.user.findUnique({
where: { email },
});
if (bestaandeGebruiker) {
return { error: "Er bestaat al een account met dit e-mailadres" };
}
const gehashtWachtwoord = await bcrypt.hash(wachtwoord, 12);
await prisma.user.create({
data: {
name: naam,
email,
password: gehashtWachtwoord,
role: "user",
},
});
return { success: true };
}
Credentials Inlogformulier
Nu kunnen we het credentials-inlogformulier toevoegen aan de inlogpagina, gecombineerd met de OAuth-knoppen. We gebruiken useActionState van React 19 voor het afhandelen van de formulierstatus:
// app/inloggen/credentials-form.tsx
"use client";
import { useActionState } from "react";
import { authenticate } from "@/app/acties/inloggen";
export function CredentialsFormulier() {
const [state, formAction, isPending] = useActionState(authenticate, {});
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
E-mailadres
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Wachtwoord
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
{state?.error && (
<p className="text-sm text-red-600">{state.error}</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded bg-green-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? "Bezig met inloggen..." : "Inloggen met e-mail"}
</button>
</form>
);
}
De bijbehorende Server Action voor het inloggen:
// app/acties/inloggen.ts
"use server";
import { signIn } from "@/auth";
import { AuthError } from "next-auth";
export async function authenticate(
prevState: { error?: string },
formData: FormData
) {
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/dashboard",
});
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return { error: "Ongeldig e-mailadres of wachtwoord" };
default:
return { error: "Er is een fout opgetreden bij het inloggen" };
}
}
throw error;
}
}
Let op het try-catch-patroon hier. De signIn-functie gooit een AuthError wanneer de inloggegevens ongeldig zijn. We vangen die fout op en retourneren een gebruikersvriendelijk foutbericht. Die throw error aan het einde is trouwens belangrijk — die zorgt ervoor dat de redirect (die technisch ook een error is in Next.js) correct wordt afgehandeld.
Sessies Gebruiken in Server Components en Client Components
Sessie in Server Components
Een van de grootste verbeteringen in Auth.js v5 is hoe makkelijk het is om de sessie op te halen in Server Components. Geen complexe context-providers of hooks nodig — gewoon een simpele auth()-aanroep:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPagina() {
const session = await auth();
if (!session?.user) {
redirect("/inloggen");
}
return (
<div>
<h1>Welkom, {session.user.name}</h1>
<p>E-mail: {session.user.email}</p>
<p>Rol: {session.user.role}</p>
</div>
);
}
De auth()-functie retourneert de sessie direct op de server, zonder extra netwerkaanroepen. Dit is veel sneller dan de oude getServerSession()-aanpak en volledig type-safe dankzij de type-uitbreiding die we eerder hebben opgezet.
Sessie in Client Components
Voor Client Components heb je een SessionProvider nodig. Wikkel je layout in deze provider:
// app/layout.tsx
import { SessionProvider } from "next-auth/react";
import { auth } from "@/auth";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
return (
<html lang="nl">
<body>
<SessionProvider session={session}>
{children}
</SessionProvider>
</body>
</html>
);
}
Nu kun je useSession() gebruiken in Client Components:
// components/gebruiker-menu.tsx
"use client";
import { useSession, signOut } from "next-auth/react";
export function GebruikerMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div className="animate-pulse h-8 w-8 rounded-full bg-gray-300" />;
}
if (!session) {
return <a href="/inloggen">Inloggen</a>;
}
return (
<div className="flex items-center gap-4">
<span>{session.user.name}</span>
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded bg-red-500 px-3 py-1 text-white"
>
Uitloggen
</button>
</div>
);
}
De SessionProvider haalt de sessie op van de server bij de eerste render en synchroniseert daarna automatisch via polling. Door de sessie door te geven als prop vanuit de server layout, vermijd je een extra client-side fetch bij het laden van de pagina. Een klein detail, maar het maakt je app merkbaar sneller.
Route Bescherming met Middleware
Basis Middleware Configuratie
Middleware is je eerste verdedigingslinie voor het beschermen van routes. Het draait op de Edge Runtime en onderschept verzoeken voordat ze je applicatie bereiken. Dat bespaart serverresources en verbetert de beveiliging aanzienlijk:
// middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
const beschermdeRoutes = ["/dashboard", "/profiel", "/instellingen"];
const adminRoutes = ["/admin"];
const authRoutes = ["/inloggen", "/registreren"];
export default auth((req) => {
const { nextUrl } = req;
const isIngelogd = !!req.auth;
const pad = nextUrl.pathname;
// Voorkom dat ingelogde gebruikers de inlogpagina bezoeken
if (authRoutes.some((route) => pad.startsWith(route))) {
if (isIngelogd) {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
return NextResponse.next();
}
// Bescherm routes die authenticatie vereisen
if (beschermdeRoutes.some((route) => pad.startsWith(route))) {
if (!isIngelogd) {
const inlogUrl = new URL("/inloggen", nextUrl);
inlogUrl.searchParams.set("callbackUrl", pad);
return NextResponse.redirect(inlogUrl);
}
}
// Bescherm admin-routes
if (adminRoutes.some((route) => pad.startsWith(route))) {
if (!isIngelogd) {
return NextResponse.redirect(new URL("/inloggen", nextUrl));
}
if (req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/geen-toegang", nextUrl));
}
}
return NextResponse.next();
});
export const config = {
matcher: [
"/((?!api/auth|_next/static|_next/image|favicon.ico).*)",
],
};
Dit is een krachtig patroon dat drie niveaus van routebescherming combineert in één middleware-functie. De matcher-configuratie zorgt ervoor dat de middleware niet draait voor statische bestanden en de Auth.js API-routes zelf.
Splitsing van Configuratie voor Edge Compatibiliteit
Een belangrijk aandachtspunt (en iets waar ik zelf ook tegenaan ben gelopen): de Edge Runtime ondersteunt niet alle Node.js-API's. Als je Prisma of bcryptjs gebruikt in je auth-configuratie, kan de middleware crashen. De oplossing is om de configuratie te splitsen:
// auth.config.ts - Alleen Edge-compatibele code
// (providers zonder database-aanroepen)
// auth.ts - Volledige configuratie met adapter
// (importeert auth.config.ts)
// middleware.ts - Importeert uit auth.ts
// Auth.js handelt de Edge-compatibiliteit intern af
Auth.js v5 lost dit grotendeels op door de authorize-callback van de Credentials-provider niet uit te voeren in middleware. De middleware controleert alleen het JWT-token, niet de database. Maar als je custom callbacks hebt die database-aanroepen doen, moet je deze wel splitsen.
Rolgebaseerde Toegangscontrole (RBAC)
De Rollenstructuur Definiëren
Oké, nu wordt het interessant. Voor een robuust RBAC-systeem definiëren we de beschikbare rollen en hun permissies als een centraal configuratiebestand:
// lib/rollen.ts
export const ROLLEN = {
user: {
label: "Gebruiker",
permissies: [
"profiel:lezen",
"profiel:bewerken",
"artikelen:lezen",
],
},
editor: {
label: "Redacteur",
permissies: [
"profiel:lezen",
"profiel:bewerken",
"artikelen:lezen",
"artikelen:aanmaken",
"artikelen:bewerken",
],
},
admin: {
label: "Beheerder",
permissies: [
"profiel:lezen",
"profiel:bewerken",
"artikelen:lezen",
"artikelen:aanmaken",
"artikelen:bewerken",
"artikelen:verwijderen",
"gebruikers:beheren",
"instellingen:beheren",
],
},
} as const;
export type Rol = keyof typeof ROLLEN;
export type Permissie = (typeof ROLLEN)[Rol]["permissies"][number];
export function heeftPermissie(rol: Rol, permissie: Permissie): boolean {
return ROLLEN[rol]?.permissies.includes(permissie) ?? false;
}
Server-Side Autorisatie Helper
Maak een herbruikbare helper die autorisatie afdwingt in Server Components en Server Actions:
// lib/autorisatie.ts
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { heeftPermissie, type Permissie, type Rol } from "@/lib/rollen";
export async function vereisAuth() {
const session = await auth();
if (!session?.user) {
redirect("/inloggen");
}
return session;
}
export async function vereisRol(vereist: Rol) {
const session = await vereisAuth();
const gebruikersRol = session.user.role as Rol;
if (gebruikersRol !== vereist && gebruikersRol !== "admin") {
redirect("/geen-toegang");
}
return session;
}
export async function vereisPermissie(permissie: Permissie) {
const session = await vereisAuth();
const gebruikersRol = session.user.role as Rol;
if (!heeftPermissie(gebruikersRol, permissie)) {
redirect("/geen-toegang");
}
return session;
}
RBAC Toepassen in Server Components
Nu kunnen we de helpers gebruiken in pagina's en componenten. Het is verrassend simpel:
// app/admin/page.tsx
import { vereisRol } from "@/lib/autorisatie";
export default async function AdminPagina() {
const session = await vereisRol("admin");
return (
<div>
<h1>Beheerdersdashboard</h1>
<p>Welkom, {session.user.name} (Rol: {session.user.role})</p>
{/* Admin-specifieke content */}
</div>
);
}
// app/artikelen/nieuw/page.tsx
import { vereisPermissie } from "@/lib/autorisatie";
export default async function NieuwArtikelPagina() {
await vereisPermissie("artikelen:aanmaken");
return (
<div>
<h1>Nieuw Artikel Schrijven</h1>
{/* Artikelformulier */}
</div>
);
}
RBAC in Server Actions
En dit is cruciaal: vergeet niet om autorisatie ook af te dwingen in Server Actions. Het is absoluut niet voldoende om alleen de UI te verbergen — een kwaadwillende gebruiker kan Server Actions namelijk direct aanroepen:
// app/acties/artikelen.ts
"use server";
import { vereisPermissie } from "@/lib/autorisatie";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function verwijderArtikel(artikelId: string) {
// Altijd autorisatie controleren in Server Actions!
const session = await vereisPermissie("artikelen:verwijderen");
await prisma.article.delete({
where: { id: artikelId },
});
revalidatePath("/artikelen");
}
Dit is een beveiligingsprincipe dat niet vaak genoeg herhaald kan worden: vertrouw nooit alleen op de UI voor autorisatie. Elke Server Action en API-route moet zelfstandig controleren of de gebruiker de juiste permissies heeft.
Conditionele UI op Basis van Rollen
Server Component voor Rolgebaseerde Weergave
Laten we een herbruikbaar Server Component maken dat content conditioneel weergeeft op basis van de gebruikersrol. Dit is een patroon dat je in bijna elk project nodig hebt:
// components/rol-poort.tsx
import { auth } from "@/auth";
import { heeftPermissie, type Permissie, type Rol } from "@/lib/rollen";
interface RolPoortProps {
rol?: Rol;
permissie?: Permissie;
children: React.ReactNode;
terugval?: React.ReactNode;
}
export async function RolPoort({
rol,
permissie,
children,
terugval = null,
}: RolPoortProps) {
const session = await auth();
if (!session?.user) return terugval;
const gebruikersRol = session.user.role as Rol;
if (rol && gebruikersRol !== rol && gebruikersRol !== "admin") {
return terugval;
}
if (permissie && !heeftPermissie(gebruikersRol, permissie)) {
return terugval;
}
return <>{children}</>;
}
Gebruik het RolPoort-component in je pagina's:
// app/dashboard/page.tsx
import { RolPoort } from "@/components/rol-poort";
import { vereisAuth } from "@/lib/autorisatie";
export default async function DashboardPagina() {
const session = await vereisAuth();
return (
<div>
<h1>Dashboard</h1>
{/* Zichtbaar voor iedereen die is ingelogd */}
<section>
<h2>Mijn Profiel</h2>
<p>Welkom, {session.user.name}</p>
</section>
{/* Alleen zichtbaar voor redacteuren en beheerders */}
<RolPoort permissie="artikelen:aanmaken">
<section>
<h2>Artikelbeheer</h2>
<a href="/artikelen/nieuw">Nieuw artikel schrijven</a>
</section>
</RolPoort>
{/* Alleen zichtbaar voor beheerders */}
<RolPoort rol="admin">
<section>
<h2>Beheerderspaneel</h2>
<a href="/admin">Ga naar beheer</a>
</section>
</RolPoort>
</div>
);
}
Uitloggen en Sessiebeheer
Uitloggen als Server Action
Het uitloggen kan elegant worden afgehandeld met een Server Action in een formulier:
// components/uitlog-knop.tsx
import { signOut } from "@/auth";
export function UitlogKnop() {
return (
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<button
type="submit"
className="rounded bg-red-500 px-4 py-2 text-white"
>
Uitloggen
</button>
</form>
);
}
Door signOut als Server Action te gebruiken in plaats van een client-side aanroep, werk je volledig binnen het server-first paradigma van de App Router. Dit is ook veiliger omdat het CSRF-bescherming krijgt via het standaard formuliermechanisme.
Sessie Vernieuwen en Callbacks
Auth.js v5 biedt krachtige callbacks voor het aanpassen van het sessiegedrag. Je kunt bijvoorbeeld de sessie vernieuwen wanneer de gebruikersrol verandert:
// In auth.ts - callback configuratie
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
// Sessie bijwerken wanneer update() wordt aangeroepen
if (trigger === "update" && session) {
token.role = session.user.role;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
}
Dit maakt het mogelijk om de sessie bij te werken zonder de gebruiker te dwingen opnieuw in te loggen. Handig wanneer een beheerder de rol van een gebruiker wijzigt, bijvoorbeeld.
Beveiliging en Best Practices
Defense in Depth
Een robuust authenticatiesysteem vertrouwt nooit op slechts één beveiligingslaag. Dit is iets dat ik echt wil benadrukken. Implementeer meerdere lagen:
- Middleware: De eerste verdedigingslinie — blokkeert ongeautoriseerde verzoeken voordat ze je applicatie bereiken
- Server Components: Controleer de sessie in elke pagina die beschermd moet zijn
- Server Actions: Valideer autorisatie bij elke mutatie
- API Routes: Verifieer tokens bij elke API-aanroep
- Database: Gebruik row-level security waar mogelijk
CSRF-Bescherming
Auth.js v5 biedt ingebouwde CSRF-bescherming voor alle authenticatieacties. Door signIn en signOut als Server Actions in formulieren te gebruiken, profiteer je automatisch van het CSRF-token dat Next.js genereert voor Server Actions.
Beveiligde Headers en Cookies
Auth.js configureert automatisch veilige sessie-cookies met de juiste attributen:
HttpOnly: Voorkomt toegang via JavaScript (beschermt tegen XSS)Secure: Alleen verzonden via HTTPS in productieSameSite=Lax: Beschermt tegen CSRF-aanvallen
Rate Limiting voor Inlogpogingen
Bescherm je inlogendpoint tegen brute-force aanvallen. Dit is iets wat vaak wordt overgeslagen in tutorials, maar in productie echt onmisbaar is:
// lib/rate-limit.ts
const pogingen = new Map<string, { aantal: number; verlooptOp: number }>();
export function controleerRateLimit(
sleutel: string,
maxPogingen: number = 5,
vensterMs: number = 15 * 60 * 1000
): boolean {
const nu = Date.now();
const record = pogingen.get(sleutel);
if (!record || record.verlooptOp < nu) {
pogingen.set(sleutel, { aantal: 1, verlooptOp: nu + vensterMs });
return true;
}
if (record.aantal >= maxPogingen) {
return false;
}
record.aantal++;
return true;
}
Gebruik deze rate limiter in je inlog Server Action:
// In de authenticate Server Action
import { headers } from "next/headers";
import { controleerRateLimit } from "@/lib/rate-limit";
export async function authenticate(prevState: any, formData: FormData) {
const headerLijst = await headers();
const ip = headerLijst.get("x-forwarded-for") ?? "onbekend";
if (!controleerRateLimit(`login:${ip}`)) {
return {
error: "Te veel inlogpogingen. Probeer het over 15 minuten opnieuw.",
};
}
// Ga verder met de inloglogica...
}
In productie wil je trouwens een meer robuuste rate-limiting oplossing gebruiken, zoals Upstash Redis of een vergelijkbare service die werkt op de Edge Runtime. De in-memory Map die we hier gebruiken werkt niet bij meerdere server-instanties.
Veelvoorkomende Problemen en Oplossingen
Probleem: Edge Runtime Incompatibiliteit
Als je middleware crasht met een foutmelding over ontbrekende Node.js-API's, controleer dan of je geen database-calls of Node.js-specifieke pakketten importeert in je middleware-pad. De oplossing is om je auth.config.ts gescheiden te houden van pakketten die niet op de Edge draaien.
Probleem: Sessie Bevat Geen Rol
Als session.user.role altijd undefined is, controleer dan of:
- De
jwt- ensession-callbacks correct zijn geconfigureerd - De TypeScript type-uitbreiding correct is ingesteld
- De gebruiker daadwerkelijk een rol heeft in de database
In mijn ervaring is het bijna altijd een van deze drie dingen. Check ze in deze volgorde — het scheelt je veel debugtijd.
Probleem: OAuth-Callback Fout
Als OAuth-logins mislukken met een callback-fout, controleer dan of de redirect URI in je provider-dashboard exact overeenkomt met http://localhost:3000/api/auth/callback/[provider] voor ontwikkeling, of je productie-URL voor de live omgeving. Let ook op trailing slashes — die kunnen het verschil maken.
Probleem: Credentials en Database Sessies
De Credentials-provider werkt alleen met de jwt-sessiestrategie. Als je de database-strategie probeert te gebruiken met Credentials, zal Auth.js geen sessies aanmaken. Gebruik altijd session: { strategy: "jwt" } wanneer je de Credentials-provider gebruikt. Dit is een van die gotchas die niet altijd even duidelijk in de documentatie staat.
Samenvatting en Vervolgstappen
In dit artikel hebben we een compleet authenticatiesysteem gebouwd met Auth.js v5 en de Next.js App Router. Even een overzicht van wat we allemaal hebben behandeld:
- Installatie en configuratie van Auth.js v5 met Prisma als database-adapter
- OAuth-providers (GitHub en Google) met automatische omgevingsvariabelen
- Credentials-login met wachtwoord-hashing via bcrypt en Zod-validatie
- Sessiebeheer in zowel Server Components als Client Components
- Middleware-bescherming met meerdere beveiligingsniveaus
- Rolgebaseerde toegangscontrole (RBAC) met permissies, helpers en conditionele UI
- Beveiligings-best-practices inclusief defense in depth, CSRF-bescherming en rate limiting
Dit patroon sluit naadloos aan op de andere onderwerpen in deze serie. De Server Actions die we voor authenticatie gebruiken, bouwen voort op de patronen uit de Server Actions en Formulieren-gids. De middleware-configuratie bouwt voort op de technieken uit de Middleware-gids. En het ophalen van sessiedata in Server Components past perfect bij de Data Ophalen-patronen.
Als vervolgstap kun je dit authenticatiesysteem uitbreiden met functies als e-mailverificatie, wachtwoord-reset flows, twee-factor authenticatie (2FA), en sociale account-koppeling. Auth.js v5 biedt ondersteuning voor al deze patronen, waardoor je een productieklaar authenticatiesysteem kunt bouwen dat meegroeit met je applicatie. Veel succes ermee!