Moderne Authenticatie in 2026: JWT, Sessions, OAuth en Passkeys
Het complete authenticatielandschap: wanneer sessions vs JWT, OAuth 2.0 / OIDC flows, refresh token rotation, passkeys (WebAuthn) en de Next.js auth-patronen die ik daadwerkelijk gebruik.
Authenticatie is het enige gebied in webdevelopment waar "het werkt" nooit goed genoeg is. Een bug in je datumpicker is vervelend. Een bug in je auth-systeem is een datalek.
Ik heb authenticatie vanaf nul geïmplementeerd, ben gemigreerd tussen providers, heb token-diefstal-incidenten gedebugd en de nasleep afgehandeld van "we fixen de beveiliging later"-beslissingen. Dit artikel is de uitgebreide gids die ik graag had gehad toen ik begon. Niet alleen de theorie — de daadwerkelijke afwegingen, de echte kwetsbaarheden en de patronen die standhouden onder productiedruk.
We behandelen het hele landschap: sessions, JWTs, OAuth 2.0, passkeys, MFA en autorisatie. Aan het eind begrijp je niet alleen hoe elk mechanisme werkt, maar wanneer je het moet gebruiken en waarom de alternatieven bestaan.
Sessions vs JWT: De Echte Afwegingen#
Dit is de eerste beslissing die je tegenkomt, en het internet staat vol met slecht advies erover. Laat me uitleggen wat er echt toe doet.
Session-gebaseerde Authenticatie#
Sessions zijn de originele aanpak. De server maakt een sessierecord aan, slaat het ergens op (database, Redis, geheugen) en geeft de client een ondoorzichtige sessie-ID in een cookie.
// Vereenvoudigd sessie aanmaken
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 uur
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Opslaan in je database of Redis
await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86400,
path: "/",
});
return sessionId;
}De voordelen zijn reëel:
- Directe intrekking. Verwijder het sessierecord en de gebruiker is uitgelogd. Niet wachten op expiratie. Dit is belangrijk wanneer je verdachte activiteit detecteert.
- Sessiezichtbaarheid. Je kunt gebruikers hun actieve sessies tonen ("ingelogd op Chrome, Windows 11, Istanbul") en ze individuele laten intrekken.
- Kleine cookie-grootte. De sessie-ID is doorgaans 64 tekens. De cookie groeit nooit.
- Server-side controle. Je kunt sessiedata bijwerken (een gebruiker promoveren tot admin, rechten wijzigen) en het treedt direct in werking bij het volgende verzoek.
De nadelen zijn ook reëel:
- Database-hit bij elk verzoek. Elk geauthenticeerd verzoek heeft een sessie-lookup nodig. Met Redis is dit sub-milliseconde, maar het is wel een dependency.
- Horizontale schaling vereist gedeelde opslag. Als je meerdere servers hebt, moeten ze allemaal toegang hebben tot dezelfde sessieopslag. Sticky sessions zijn een kwetsbare workaround.
- CSRF is een zorg. Omdat cookies automatisch worden verstuurd, heb je CSRF-bescherming nodig. SameSite-cookies lossen dit grotendeels op, maar je moet begrijpen waarom.
JWT-gebaseerde Authenticatie#
JWTs draaien het model om. In plaats van sessiestaat op te slaan op de server, codeer je het in een ondertekend token dat de client vasthoudt.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.setIssuer("https://akousa.net")
.setAudience("https://akousa.net")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://akousa.net",
audience: "https://akousa.net",
});
return payload;
} catch {
return null;
}
}De voordelen:
- Geen server-side opslag. Het token is zelfvoorzienend. Je verifieert de handtekening en leest de claims. Geen database-hit.
- Werkt over services heen. In een microservices-architectuur kan elke service met de publieke sleutel het token verifiëren. Geen gedeelde sessieopslag nodig.
- Stateless schaling. Voeg meer servers toe zonder je zorgen te maken over sessie-affiniteit.
De nadelen — en dit zijn de nadelen die mensen overslaan:
- Je kunt een JWT niet intrekken. Eenmaal uitgegeven is het geldig tot het verloopt. Als het account van een gebruiker is gecompromitteerd, kun je niet forceren uitloggen. Je kunt een blokkeerlijst bouwen, maar dan heb je server-side state herintroduceerd en het belangrijkste voordeel verloren.
- Tokengrootte. JWTs met een paar claims zijn doorgaans 800+ bytes. Voeg rollen, rechten en metadata toe en je stuurt kilobytes bij elk verzoek.
- Payload is leesbaar. De payload is Base64-gecodeerd, niet versleuteld. Iedereen kan het decoderen. Zet nooit gevoelige gegevens in een JWT.
- Kloksynchronisatieproblemen. Als je servers verschillende klokken hebben (het gebeurt), worden verloopcontroles onbetrouwbaar.
Wanneer Welke Gebruiken#
Mijn vuistregel:
Gebruik sessions wanneer: Je een monolithische applicatie hebt, je directe intrekking nodig hebt, je een consumentgericht product bouwt waar accountbeveiliging cruciaal is, of je auth-eisen vaak kunnen veranderen.
Gebruik JWTs wanneer: Je een microservices-architectuur hebt waar services onafhankelijk identiteit moeten verifiëren, je API-naar-API communicatie bouwt, of je een authenticatiesysteem voor derden implementeert.
In de praktijk: De meeste applicaties zouden sessions moeten gebruiken. Het "JWTs zijn schaalbaarder" argument is alleen van toepassing als je daadwerkelijk een schaalprobleem hebt dat sessieopslag niet kan oplossen — en Redis handelt miljoenen sessie-lookups per seconde af. Ik heb te veel projecten gezien die JWTs kozen omdat ze moderner klinken, om vervolgens een blokkeerlijst en een refresh token-systeem te bouwen dat complexer is dan sessions zouden zijn geweest.
JWT Deep Dive#
Zelfs als je kiest voor session-gebaseerde auth, zul je JWTs tegenkomen via OAuth, OIDC en integraties met derden. De interne werking begrijpen is niet onderhandelbaar.
Anatomie van een JWT#
Een JWT heeft drie delen gescheiden door punten: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — declareert het algoritme en tokentype:
{
"alg": "RS256",
"typ": "JWT"
}Payload — bevat claims. Standaard claims hebben korte namen:
{
"sub": "user_123", // Subject (over wie gaat dit)
"iss": "https://auth.example.com", // Issuer (wie heeft dit gemaakt)
"aud": "https://api.example.com", // Audience (wie moet dit accepteren)
"iat": 1709312000, // Issued At (Unix-timestamp)
"exp": 1709312900, // Expiration (Unix-timestamp)
"role": "admin" // Aangepaste claim
}Signature — bewijst dat het token niet is gemanipuleerd. Gemaakt door de gecodeerde header en payload te ondertekenen met een geheime sleutel.
RS256 vs HS256: Dit Maakt Echt Uit#
HS256 (HMAC-SHA256) — symmetrisch. Hetzelfde geheim ondertekent en verifieert. Simpel, maar elke service die tokens moet verifiëren moet het geheim hebben. Als er één van gecompromitteerd wordt, kan een aanvaller tokens vervalsen.
RS256 (RSA-SHA256) — asymmetrisch. Een privésleutel ondertekent, een publieke sleutel verifieert. Alleen de auth-server heeft de privésleutel nodig. Elke service kan verifiëren met de publieke sleutel. Als een verificatieservice wordt gecompromitteerd, kan de aanvaller tokens lezen maar niet vervalsen.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — gebruik dit wanneer meerdere services tokens verifiëren
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
async function signWithRS256(payload: Record<string, unknown>) {
const privateKey = await importPKCS8(privateKeyPem, "RS256");
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function verifyWithRS256(token: string) {
const publicKey = await importSPKI(publicKeyPem, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // CRUCIAAL: beperk altijd de algoritmen
});
return payload;
}Regel: Gebruik RS256 wanneer tokens service-grenzen overschrijden. Gebruik HS256 alleen wanneer dezelfde service zowel ondertekent als verifieert.
De alg: none Aanval#
Dit is de beroemdste JWT-kwetsbaarheid, en hij is beschamend simpel. Sommige JWT-bibliotheken deden vroeger:
- Het
alg-veld uit de header lezen - Welk algoritme het ook aangaf gebruiken
- Als
alg: "none", handtekeningverificatie volledig overslaan
Een aanvaller kon een geldige JWT nemen, de payload wijzigen (bijv. "role": "admin" instellen), alg op "none" zetten, de handtekening verwijderen en het versturen. De server zou het accepteren.
// KWETSBAAR — doe dit nooit
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "Geen handtekening nodig" — CATASTROFAAL
return JSON.parse(atob(payloadB64));
}
// ... verifieer handtekening
}De oplossing is simpel: specificeer altijd het verwachte algoritme expliciet. Laat het token nooit vertellen hoe het geverifieerd moet worden.
// VEILIG — algoritme is hardgecodeerd, niet gelezen uit het token
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Accepteer alleen RS256 — negeer de header
});Moderne bibliotheken zoals jose handelen dit standaard correct af, maar je zou nog steeds expliciet de algorithms-optie moeten meegeven als defense in depth.
Algorithm Confusion Aanval#
Gerelateerd aan het bovenstaande: als een server geconfigureerd is om RS256 te accepteren, zou een aanvaller kunnen:
- De publieke sleutel van de server ophalen (het is tenslotte publiek)
- Een token maken met
alg: "HS256" - Het ondertekenen met de publieke sleutel als het HMAC-geheim
Als de server de alg header leest en overschakelt naar HS256-verificatie, wordt de publieke sleutel (die iedereen kent) het gedeelde geheim. De handtekening is geldig. De aanvaller heeft een token vervalst.
Nogmaals, de oplossing is dezelfde: vertrouw nooit het algoritme uit de token-header. Codeer het altijd hard.
Refresh Token Rotation#
Als je JWTs gebruikt, heb je een refresh token-strategie nodig. Een langlevend access token versturen is vragen om problemen — als het gestolen wordt, heeft de aanvaller toegang voor de hele levensduur.
Het patroon:
- Access token: kortlevend (15 minuten). Wordt gebruikt voor API-verzoeken.
- Refresh token: langlevend (30 dagen). Wordt alleen gebruikt om een nieuw access token te krijgen.
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Groepeert gerelateerde tokens samen
used: boolean;
expiresAt: Date;
createdAt: Date;
}
async function issueTokenPair(userId: string) {
const familyId = randomBytes(16).toString("hex");
const accessToken = await createAccessToken(userId);
const refreshToken = randomBytes(64).toString("hex");
const refreshTokenHash = await hashToken(refreshToken);
// Refresh token record opslaan
await db.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId,
familyId,
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
return { accessToken, refreshToken };
}Rotatie bij Elk Gebruik#
Elke keer dat de client een refresh token gebruikt om een nieuw access token te krijgen, geef je een nieuw refresh token uit en maak je het oude ongeldig:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Token bestaat niet — mogelijke diefstal
return null;
}
if (record.expiresAt < new Date()) {
// Token verlopen
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// DIT TOKEN IS AL GEBRUIKT.
// Iemand speelt het opnieuw af — of de legitieme gebruiker
// of een aanvaller. Hoe dan ook, vernietig de hele familie.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Refresh token hergebruik gedetecteerd voor gebruiker ${record.userId}, familie ${record.familyId}. Alle tokens in de familie ongeldig gemaakt.`
);
return null;
}
// Markeer huidig token als gebruikt (niet verwijderen — we hebben het nodig voor hergebruikdetectie)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Geef nieuw paar uit met dezelfde familie-ID
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // Zelfde familie
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
const newAccessToken = await createAccessToken(record.userId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}Waarom Familie-invalidatie Ertoe Doet#
Beschouw dit scenario:
- Gebruiker logt in, krijgt refresh token A
- Aanvaller steelt refresh token A
- Aanvaller gebruikt A om een nieuw paar te krijgen (access token + refresh token B)
- Gebruiker probeert A (dat ze nog hebben) te gebruiken om te vernieuwen
Zonder hergebruikdetectie krijgt de gebruiker gewoon een foutmelding. De aanvaller gaat door met token B. De gebruiker logt opnieuw in, zonder ooit te weten dat hun account gecompromitteerd was.
Met hergebruikdetectie en familie-invalidatie: wanneer de gebruiker het al-gebruikte token A probeert te gebruiken, detecteert het systeem hergebruik, maakt elk token in de familie ongeldig (inclusief B), en dwingt zowel de gebruiker als de aanvaller opnieuw te authenticeren. De gebruiker krijgt een "log alsjeblieft opnieuw in" melding en merkt misschien dat er iets mis is.
Dit is de aanpak die gebruikt wordt door Auth0, Okta en Auth.js. Het is niet perfect — als de aanvaller het token gebruikt voordat de legitieme gebruiker dat doet, wordt de legitieme gebruiker degene die de hergebruikwaarschuwing triggert. Maar het is het beste wat we kunnen doen met bearer tokens.
OAuth 2.0 & OIDC#
OAuth 2.0 en OpenID Connect zijn de protocollen achter "Inloggen met Google/GitHub/Apple." Ze begrijpen is essentieel, zelfs als je een library gebruikt, want wanneer dingen breken — en dat zullen ze — moet je weten wat er op protocolniveau gebeurt.
Het Belangrijke Onderscheid#
OAuth 2.0 is een autorisatie-protocol. Het beantwoordt: "Mag deze applicatie de data van deze gebruiker benaderen?" Het resultaat is een access token dat specifieke rechten (scopes) verleent.
OpenID Connect (OIDC) is een authenticatie-laag gebouwd bovenop OAuth 2.0. Het beantwoordt: "Wie is deze gebruiker?" Het resultaat is een ID token (een JWT) dat gebruikersidentiteitsinformatie bevat.
Wanneer je "Inloggen met Google" gebruikt, gebruik je OIDC. Google vertelt je app wie de gebruiker is (authenticatie). Je kunt ook OAuth-scopes aanvragen om toegang te krijgen tot hun agenda of drive (autorisatie).
Authorization Code Flow met PKCE#
Dit is de flow die je moet gebruiken voor webapplicaties. PKCE (Proof Key for Code Exchange) was oorspronkelijk ontworpen voor mobiele apps maar wordt nu aanbevolen voor alle clients, inclusief server-side applicaties.
import { randomBytes, createHash } from "crypto";
// Stap 1: Genereer PKCE-waarden en stuur de gebruiker door
function initiateOAuthFlow() {
// Code verifier: willekeurige string van 43-128 tekens
const codeVerifier = randomBytes(32)
.toString("base64url")
.slice(0, 43);
// Code challenge: SHA256-hash van de verifier, base64url-gecodeerd
const codeChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
// State: willekeurige waarde voor CSRF-bescherming
const state = randomBytes(16).toString("hex");
// Sla beide op in de sessie (server-side!) voordat je doorverwijst
// Zet de code_verifier NOOIT in een cookie of URL-parameter
session.codeVerifier = codeVerifier;
session.oauthState = state;
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl.toString();
}// Stap 2: Verwerk de callback
async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// Controleer op fouten van de provider
if (error) {
throw new Error(`OAuth-fout: ${error}`);
}
// Verifieer dat state overeenkomt (CSRF-bescherming)
if (state !== session.oauthState) {
throw new Error("State mismatch — mogelijke CSRF-aanval");
}
// Wissel de autorisatiecode in voor tokens
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: "https://example.com/api/auth/callback/google",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: session.codeVerifier, // PKCE: bewijst dat wij deze flow gestart hebben
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — voor API-aanroepen naar Google
// tokens.id_token — JWT met gebruikersidentiteit (OIDC)
// tokens.refresh_token — voor het ophalen van nieuwe access tokens
// Stap 3: Verifieer het ID token en extraheer gebruikersinfo
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}De Drie Endpoints#
Elke OAuth/OIDC-provider stelt deze bloot:
- Authorization endpoint — waar je de gebruiker naartoe stuurt om in te loggen en rechten te verlenen. Geeft een autorisatiecode terug.
- Token endpoint — waar je server de autorisatiecode inwisselt voor access/refresh/ID tokens. Dit is een server-naar-server aanroep.
- UserInfo endpoint — waar je aanvullende gebruikersprofieldata kunt ophalen met het access token. Bij OIDC zit veel hiervan al in het ID token.
De State Parameter#
De state-parameter voorkomt CSRF-aanvallen op de OAuth-callback. Zonder:
- Aanvaller start een OAuth-flow op eigen machine, krijgt een autorisatiecode
- Aanvaller maakt een URL:
https://jouwapp.com/callback?code=AANVALLER_CODE - Aanvaller misleidt een slachtoffer om erop te klikken (e-maillink, verborgen afbeelding)
- Jouw app wisselt de code van de aanvaller in en koppelt het Google-account van de aanvaller aan de sessie van het slachtoffer
Met state: je app genereert een willekeurige waarde, slaat het op in de sessie en neemt het op in de autorisatie-URL. Wanneer de callback binnenkomt, verifieer je dat state overeenkomt. De aanvaller kan dit niet vervalsen omdat ze geen toegang hebben tot de sessie van het slachtoffer.
Auth.js (NextAuth) met Next.js App Router#
Auth.js is waar ik als eerste naar grijp in de meeste Next.js-projecten. Het handelt de OAuth-dans, sessiebeheer, databasepersistentie en CSRF-bescherming af. Hier is een productierijpe setup.
Basisconfiguratie#
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
// Gebruik database-sessies (niet JWT) voor betere beveiliging
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 dagen
updateAge: 24 * 60 * 60, // Verleng sessie elke 24 uur
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Vraag specifieke scopes aan
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Ontvang refresh token
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// E-mail/wachtwoord login (gebruik voorzichtig)
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
// Bepaal wie mag inloggen
async signIn({ user, account }) {
// Blokkeer inloggen voor verbannen gebruikers
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Voeg aangepaste velden toe aan de sessie
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Haal rol op uit database
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
});
session.user.role = dbUser?.role ?? "user";
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});Route Handler#
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Middleware-bescherming#
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login")
|| req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
|| req.nextUrl.pathname.startsWith("/settings")
|| req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// Stuur ingelogde gebruikers weg van auth-pagina's
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Stuur niet-geauthenticeerde gebruikers naar login
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
);
}
// Controleer admin-rol
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: [
"/dashboard/:path*",
"/settings/:path*",
"/admin/:path*",
"/login",
"/register",
],
};De Sessie Gebruiken in Server Components#
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
);
}De Sessie Gebruiken in Client Components#
"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Loading...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Sign In</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "User"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkeys (WebAuthn)#
Passkeys zijn de meest significante authenticatieverbetering in jaren. Ze zijn phishing-bestendig, replay-bestendig en elimineren de hele categorie van wachtwoordgerelateerde kwetsbaarheden. Als je in 2026 een nieuw project begint, zou je passkeys moeten ondersteunen.
Hoe Passkeys Werken#
Passkeys gebruiken publieke-sleutelcryptografie, ondersteund door biometrie of apparaat-PINs:
- Registratie: De browser genereert een sleutelpaar. De privésleutel blijft op het apparaat (in een beveiligde enclave, beschermd door biometrie). De publieke sleutel wordt naar je server gestuurd.
- Authenticatie: De server stuurt een challenge (willekeurige bytes). Het apparaat ondertekent de challenge met de privésleutel (na biometrische verificatie). De server verifieert de handtekening met de opgeslagen publieke sleutel.
Er gaat nooit een gedeeld geheim over het netwerk. Er is niets om te phishen, niets om te lekken, niets om te stuffelen.
Waarom Passkeys Phishing-bestendig Zijn#
Wanneer een passkey wordt aangemaakt, is deze gebonden aan de origin (bijv. https://example.com). De browser gebruikt de passkey alleen op de exacte origin waarvoor deze is aangemaakt. Als een aanvaller een lookalike-site maakt op https://exarnple.com, wordt de passkey simpelweg niet aangeboden. Dit wordt afgedwongen door de browser, niet door de waakzaamheid van de gebruiker.
Dit is fundamenteel anders dan wachtwoorden, waarbij gebruikers routinematig hun inloggegevens invoeren op phishing-sites omdat de pagina er goed uitziet.
Implementatie met SimpleWebAuthn#
SimpleWebAuthn is de library die ik aanbeveel. Het handelt het WebAuthn-protocol correct af en heeft goede TypeScript-types.
// Server-side: Registratie
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
async function startRegistration(userId: string, userEmail: string) {
// Haal bestaande passkeys van de gebruiker op om ze uit te sluiten
const existingCredentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: "none", // We hebben geen attestatie nodig voor de meeste apps
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Sla de challenge tijdelijk op — we hebben het nodig voor verificatie
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300 // 5 minuten verlooptijd
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Challenge verlopen of niet gevonden");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Registratieverificatie mislukt: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registratieverificatie mislukt");
}
const { credential } = verification.registrationInfo;
// Sla de credential op in de database
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Opruimen
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Server-side: Authenticatie
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// Als we de gebruiker kennen (bijv. ze hebben hun e-mail ingevoerd), beperk tot hun passkeys
if (userId) {
const credentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: "preferred",
});
// Sla challenge op voor verificatie
const challengeKey = userId
? `webauthn:auth:${userId}`
: `webauthn:auth:${options.challenge}`;
await redis.set(challengeKey, options.challenge, "EX", 300);
return options;
}
async function finishAuthentication(
response: any,
expectedChallenge: string,
userId: string
) {
const credential = await db.credential.findUnique({
where: { credentialId: response.id },
});
if (!credential) {
throw new Error("Credential niet gevonden");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error("Authenticatieverificatie mislukt");
}
// BELANGRIJK: Werk de teller bij om replay-aanvallen te voorkomen
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Client-side: Registratie
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Haal opties op van je server
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// Dit triggert de passkey-UI van de browser (biometrische prompt)
const credential = await webAuthnRegister(options);
// Stuur de credential naar je server voor verificatie
const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
const result = await verifyResponse.json();
if (result.verified) {
console.log("Passkey succesvol geregistreerd!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("Gebruiker heeft de passkey-registratie geannuleerd");
}
}
}Attestatie vs Assertie#
Twee termen die je zult tegenkomen:
- Attestatie (registratie): Het proces van het aanmaken van een nieuwe credential. De authenticator "attesteert" zijn identiteit en mogelijkheden. Voor de meeste applicaties hoef je attestatie niet te verifiëren — stel
attestationType: "none"in. - Assertie (authenticatie): Het proces van het gebruiken van een bestaande credential om een challenge te ondertekenen. De authenticator "asserteert" dat de gebruiker is wie ze beweren te zijn.
MFA-implementatie#
Zelfs met passkeys kom je scenario's tegen waar MFA via TOTP nodig is — passkeys als tweede factor naast wachtwoorden, of het ondersteunen van gebruikers wiens apparaten geen passkeys ondersteunen.
TOTP (Time-Based One-Time Passwords)#
TOTP is het protocol achter Google Authenticator, Authy en 1Password. Het werkt als volgt:
- Server genereert een willekeurig geheim (base32-gecodeerd)
- Gebruiker scant een QR-code die het geheim bevat
- Zowel server als authenticator-app berekenen dezelfde 6-cijferige code uit het geheim en de huidige tijd
- Codes veranderen elke 30 seconden
import { createHmac, randomBytes } from "crypto";
// Genereer een TOTP-geheim voor een gebruiker
function generateTOTPSecret(): string {
const buffer = randomBytes(20);
return base32Encode(buffer);
}
function base32Encode(buffer: Buffer): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let result = "";
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 0x1f];
}
return result;
}
// Genereer de TOTP-URI voor QR-code
function generateTOTPUri(
secret: string,
userEmail: string,
issuer: string = "akousa.net"
): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// Verifieer een TOTP-code
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Controleer huidige tijdstap en aangrenzende (klokdrifttolerantie)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Constante-tijd vergelijking om timing-aanvallen te voorkomen
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Converteer tijdstap naar 8-byte big-endian buffer
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Dynamische truncatie
const offset = hmac[hmac.length - 1] & 0x0f;
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, "0");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return createHmac("sha256", "key").update(bufA).digest()
.equals(createHmac("sha256", "key").update(bufB).digest());
}Reservecodes#
Gebruikers verliezen hun telefoon. Genereer altijd reservecodes tijdens MFA-setup:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
randomBytes(4).toString("hex").toUpperCase() // 8-tekens hexcodes
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Hash de codes voor opslag — behandel ze als wachtwoorden
const hashedCodes = codes.map((code) =>
createHash("sha256").update(code).digest("hex")
);
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Geef de onversleutelde codes EENMALIG terug zodat de gebruiker ze kan bewaren
// Hierna hebben we alleen de hashes
return codes;
}
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = createHash("sha256")
.update(code.toUpperCase().replace(/\s/g, ""))
.digest("hex");
const backupCode = await db.backupCode.findFirst({
where: {
userId,
codeHash,
used: false,
},
});
if (!backupCode) return false;
// Markeer als gebruikt — elke reservecode werkt precies eenmaal
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}Herstelflow#
MFA-herstel is het deel dat de meeste tutorials overslaan en de meeste echte applicaties verprutsen. Dit is wat ik implementeer:
- Primair: TOTP-code van authenticator-app
- Secundair: Een van de 10 reservecodes
- Laatste redmiddel: E-mail-gebaseerd herstel met een wachttijd van 24 uur en melding aan de andere geverifieerde kanalen van de gebruiker
De wachttijd is cruciaal. Als een aanvaller de e-mail van de gebruiker heeft gecompromitteerd, wil je niet dat ze direct MFA kunnen uitschakelen. De vertraging van 24 uur geeft de legitieme gebruiker tijd om de e-mail op te merken en in te grijpen.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Onthul niet of het account bestaat
return { message: "Als dat e-mailadres bestaat, hebben we herstelinstructies gestuurd." };
}
const recoveryToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
await db.recoveryRequest.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 uur
status: "pending",
},
});
// Stuur e-mail met herstellink
await sendEmail(email, {
subject: "Accountherstelverzoek",
body: `
Er is een verzoek gedaan om MFA op je account uit te schakelen.
Als jij dit was, klik na 24 uur op onderstaande link: ...
Als jij dit NIET was, wijzig dan onmiddellijk je wachtwoord.
`,
});
return { message: "Als dat e-mailadres bestaat, hebben we herstelinstructies gestuurd." };
}Autorisatiepatronen#
Authenticatie vertelt je wie iemand is. Autorisatie vertelt je wat ze mogen doen. Dit verkeerd doen is hoe je in het nieuws belandt.
RBAC vs ABAC#
RBAC (Role-Based Access Control): Gebruikers hebben rollen, rollen hebben rechten. Simpel, makkelijk te begrijpen, werkt voor de meeste applicaties.
// RBAC — eenvoudige rolcontroles
type Role = "user" | "editor" | "admin" | "super_admin";
const ROLE_PERMISSIONS: Record<Role, string[]> = {
user: ["read:own_profile", "update:own_profile", "read:posts"],
editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
admin: [
"read:own_profile", "update:own_profile",
"read:posts", "create:posts", "update:posts", "delete:posts",
"read:users", "update:users",
],
super_admin: ["*"], // Voorzichtig met wildcards
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Gebruik in een API-route
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (Attribute-Based Access Control): Rechten hangen af van attributen van de gebruiker, de resource en de context. Flexibeler maar complexer.
// ABAC — wanneer RBAC niet genoeg is
interface PolicyContext {
user: {
id: string;
role: string;
department: string;
clearanceLevel: number;
};
resource: {
type: string;
ownerId: string;
classification: string;
department: string;
};
action: string;
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
function evaluatePolicy(context: PolicyContext): boolean {
const { user, resource, action, environment } = context;
// Gebruikers kunnen altijd hun eigen resources lezen
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Admins kunnen elke resource in hun afdeling lezen
if (
action === "read" &&
user.role === "admin" &&
user.department === resource.department
) {
return true;
}
// Geclassificeerde resources vereisen MFA en minimale clearance
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Destructieve acties geblokkeerd buiten kantooruren
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Standaard weigeren
}De "Controleer aan de Grens" Regel#
Dit is het allerbelangrijkste autorisatieprincipe: controleer rechten bij elke vertrouwensgrens, niet alleen op UI-niveau.
// FOUT — alleen controleren in het component
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// Dit verbergt de knop, maar voorkomt verwijdering niet
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Delete</button>;
}
// OOK FOUT — controleren in een server action maar niet de API-route
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Forbidden");
await db.post.delete({ where: { id: postId } });
}
// Een aanvaller kan nog steeds direct POST /api/posts/123 aanroepen
// GOED — controleer bij elke grens
// 1. Verberg de knop in de UI (UX, niet beveiliging)
// 2. Controleer in de server action (defense in depth)
// 3. Controleer in de API-route (de daadwerkelijke beveiligingsgrens)
// 4. Optioneel, controleer in middleware (voor route-level bescherming)De UI-controle is voor gebruikerservaring. De servercontrole is voor beveiliging. Vertrouw nooit op slechts één ervan.
Rechtencontroles in Next.js Middleware#
Middleware draait voor elk gematcht verzoek. Het is een goede plek voor grofmazige toegangscontrole:
// "Mag deze gebruiker deze sectie überhaupt benaderen?"
// Fijnmazige controles ("Mag deze gebruiker DEZE post bewerken?") horen in de route handler
// omdat middleware niet makkelijk toegang heeft tot de request body of route params.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Route-level toegangscontrole
const routeAccess: Record<string, Role[]> = {
"/admin": ["admin", "super_admin"],
"/editor": ["editor", "admin", "super_admin"],
"/dashboard": ["user", "editor", "admin", "super_admin"],
};
for (const [route, allowedRoles] of Object.entries(routeAccess)) {
if (path.startsWith(route)) {
if (!role || !allowedRoles.includes(role as Role)) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
}
return NextResponse.next();
});Veelvoorkomende Kwetsbaarheden#
Dit zijn de aanvallen die ik het vaakst tegenkom in echte codebases. Ze begrijpen is essentieel.
Sessiefixatie#
De aanval: Een aanvaller maakt een geldige sessie op je site, misleidt dan een slachtoffer om die sessie-ID te gebruiken (bijv. via een URL-parameter of door een cookie in te stellen via een subdomein). Wanneer het slachtoffer inlogt, heeft de sessie van de aanvaller nu een geauthenticeerde gebruiker.
De oplossing: Genereer altijd de sessie-ID opnieuw na succesvolle authenticatie. Laat nooit een pre-authenticatie sessie-ID doorlopen naar een post-authenticatie sessie.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Ongeldige inloggegevens");
// CRUCIAAL: Verwijder de oude sessie en maak een nieuwe aan
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Maak een volledig nieuwe sessie aan met een nieuwe ID
const newSessionId = await createSession(user.id, request);
return newSessionId;
}CSRF (Cross-Site Request Forgery)#
De aanval: Een gebruiker is ingelogd op je site. Ze bezoeken een kwaadaardige pagina die een verzoek doet naar je site. Omdat cookies automatisch worden verstuurd, is het verzoek geauthenticeerd.
De moderne oplossing: SameSite cookies. Het instellen van SameSite: Lax (de standaard in de meeste browsers nu) voorkomt dat cookies worden verstuurd bij cross-origin POST-verzoeken, wat de meeste CSRF-scenario's dekt.
// SameSite=Lax dekt de meeste CSRF-scenario's:
// - Blokkeert cookies bij cross-origin POST, PUT, DELETE
// - Staat cookies toe bij cross-origin GET (top-level navigatie)
// Dit is prima omdat GET-verzoeken geen side-effects zouden moeten hebben
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // Dit is je CSRF-bescherming
maxAge: 86400,
path: "/",
});Voor APIs die JSON accepteren, krijg je extra bescherming gratis: de Content-Type: application/json header kan niet worden ingesteld door HTML-formulieren, en CORS voorkomt dat JavaScript op andere origins verzoeken doet met aangepaste headers.
Als je sterkere garanties nodig hebt (bijv. je accepteert formulierinzendingen), gebruik het double-submit cookie-patroon of een synchronizer token. Auth.js regelt dit voor je.
Open Redirects in OAuth#
De aanval: Een aanvaller maakt een OAuth-callback URL die doorverwijst naar hun site na authenticatie: https://jouwapp.com/callback?redirect_to=https://evil.com/steal-token
Als je callback-handler blindelings doorverwijst naar de redirect_to parameter, belandt de gebruiker op de site van de aanvaller, mogelijk met tokens in de URL.
// KWETSBAAR
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... authenticeer de gebruiker ...
return Response.redirect(redirectTo); // Kan https://evil.com zijn!
}
// VEILIG
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Valideer de redirect-URL
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... authenticeer de gebruiker ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Sta alleen redirects toe naar dezelfde origin
if (url.origin !== base.origin) {
return "/";
}
// Sta alleen pad-redirects toe (geen javascript: of data: URIs)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Tokenlekkage via Referrer#
Als je tokens in URLs plaatst (doe het niet), lekken ze via de Referer header wanneer gebruikers op links klikken. Dit heeft echte datalekken veroorzaakt, onder andere bij GitHub.
Regels:
- Zet nooit tokens in URL-queryparameters voor authenticatie
- Stel
Referrer-Policy: strict-origin-when-cross-originin (of strenger) - Als je tokens in URLs moet plaatsen (bijv. e-mailverificatielinks), maak ze eenmalig en kortlevend
// In je Next.js middleware of layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT Key Injection#
Een minder bekende aanval: sommige JWT-bibliotheken ondersteunen een jwk of jku header die de verifier vertelt waar de publieke sleutel te vinden is. Een aanvaller kan:
- Hun eigen sleutelpaar genereren
- Een JWT maken met hun payload en ondertekenen met hun privésleutel
- De
jwkheader instellen om naar hun publieke sleutel te wijzen
Als je library blindelings de sleutel ophaalt en gebruikt uit de jwk header, verifieert de handtekening. De oplossing: laat het token nooit zijn eigen verificatiesleutel specificeren. Gebruik altijd sleutels uit je eigen configuratie.
Mijn Auth Stack in 2026#
Na jaren van het bouwen van authenticatiesystemen, hier is wat ik tegenwoordig daadwerkelijk gebruik.
Voor de Meeste Projecten: Auth.js + PostgreSQL + Passkeys#
Dit is mijn standaardstack voor nieuwe projecten:
- Auth.js (v5) voor het zware werk: OAuth-providers, sessiebeheer, CSRF, database-adapter
- PostgreSQL met Prisma-adapter voor sessie- en accountopslag
- Passkeys via SimpleWebAuthn als primaire inlogmethode voor nieuwe gebruikers
- E-mail/wachtwoord als fallback voor gebruikers die geen passkeys kunnen gebruiken
- TOTP MFA als tweede factor voor wachtwoordgebaseerde logins
De sessiestrategie is database-backed (niet JWT), wat me directe intrekking en eenvoudig sessiebeheer geeft.
// Dit is mijn typische auth.ts voor een nieuw project
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google,
GitHub,
Passkey({
// Auth.js v5 heeft ingebouwde passkey-ondersteuning
// Dit gebruikt SimpleWebAuthn onder de motorkap
}),
],
experimental: {
enableWebAuthn: true,
},
});Wanneer Clerk of Auth0 Gebruiken#
Ik grijp naar een beheerde auth-provider wanneer:
- Het project enterprise SSO nodig heeft (SAML, SCIM). SAML correct implementeren is een project van meerdere maanden. Clerk doet het standaard.
- Het team geen beveiligingsexpertise heeft. Als niemand in het team PKCE kan uitleggen, zouden ze auth niet vanaf nul moeten bouwen.
- Time to market belangrijker is dan kosten. Auth.js is gratis maar kost dagen om correct op te zetten. Clerk kost een middag.
- Je compliance-garanties nodig hebt (SOC 2, HIPAA). Beheerde providers regelen de compliance-certificering.
De afwegingen van beheerde providers:
- Kosten: Clerk rekent per maandelijks actieve gebruiker. Op schaal loopt dit op.
- Vendor lock-in: Wegmigreren van Clerk of Auth0 is pijnlijk. Je gebruikerstabel staat op hun servers.
- Aanpassingslimieten: Als je auth-flow ongebruikelijk is, zul je vechten met de meningen van de provider.
- Latency: Elke auth-controle gaat naar een third-party API. Met database-sessies is het een lokale query.
Wat Ik Vermijd#
- Mijn eigen crypto rollen. Ik gebruik
josevoor JWTs,@simplewebauthn/servervoor passkeys,bcryptofargon2voor wachtwoorden. Nooit zelfgemaakt. - Wachtwoorden opslaan in SHA256. Gebruik bcrypt (cost factor 12+) of argon2id. SHA256 is te snel — een aanvaller kan miljarden hashes per seconde proberen met een GPU.
- Langlevende access tokens. Maximaal 15 minuten. Gebruik refresh token rotation voor langere sessies.
- Symmetrische geheimen voor cross-service verificatie. Als meerdere services tokens moeten verifiëren, gebruik RS256 met een publiek/privé sleutelpaar.
- Aangepaste sessie-IDs met onvoldoende entropie. Gebruik minimaal
crypto.randomBytes(32). UUID v4 is acceptabel maar minder entropie dan ruwe willekeurige bytes.
Wachtwoord-hashing: De Correcte Manier#
Aangezien we het noemden — hier is hoe je wachtwoorden correct hasht in 2026:
import { hash, verify } from "@node-rs/argon2";
// Argon2id is het aanbevolen algoritme
// Dit zijn redelijke standaarden voor een webapplicatie
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iteraties
parallelism: 4, // 4 threads
});
}
async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}Waarom argon2id boven bcrypt? Argon2id is geheugen-hard, wat betekent dat het aanvallen niet alleen CPU-kracht vereist maar ook grote hoeveelheden RAM. Dit maakt GPU- en ASIC-aanvallen aanzienlijk duurder. Bcrypt is nog steeds prima — het is niet gebroken — maar argon2id is de betere keuze voor nieuwe projecten.
Beveiligingschecklist#
Verifieer voordat je een authenticatiesysteem shipt:
- Wachtwoorden zijn gehasht met argon2id of bcrypt (cost 12+)
- Sessies worden opnieuw gegenereerd na inloggen (voorkomt sessiefixatie)
- Cookies zijn
HttpOnly,Secure,SameSite=LaxofStrict - JWTs specificeren algoritmen expliciet (vertrouw nooit de
algheader) - Access tokens verlopen in 15 minuten of minder
- Refresh token rotation is geïmplementeerd met hergebruikdetectie
- OAuth state parameter wordt geverifieerd (CSRF-bescherming)
- Redirect-URLs worden gevalideerd tegen een allowlist
- Rate limiting is toegepast op login-, registratie- en wachtwoordreset-endpoints
- Mislukte inlogpogingen worden gelogd met IP maar niet met wachtwoorden
- Accountvergrendeling na N mislukte pogingen (met progressieve vertragingen, geen permanente vergrendeling)
- Wachtwoordresettokens zijn eenmalig en verlopen in 1 uur
- MFA-reservecodes worden gehasht als wachtwoorden
- CORS is geconfigureerd om alleen bekende origins toe te staan
-
Referrer-Policyheader is ingesteld - Geen gevoelige gegevens in JWT-payloads (ze zijn leesbaar voor iedereen)
- WebAuthn-teller wordt geverifieerd en bijgewerkt (voorkomt credential-klonen)
Deze lijst is niet uitputtend, maar het dekt de kwetsbaarheden die ik het vaakst heb gezien in productiesystemen.
Afsluiting#
Authenticatie is een van die domeinen waar het landschap blijft evolueren, maar de fundamenten hetzelfde blijven: verifieer identiteit, geef het minimum noodzakelijke credentials uit, controleer rechten bij elke grens, en ga uit van een inbreuk.
De grootste verschuiving in 2026 is dat passkeys mainstream worden. Browserondersteuning is universeel, platformondersteuning (iCloud Keychain, Google Password Manager) maakt de UX naadloos, en de beveiligingseigenschappen zijn oprecht superieur aan alles wat we eerder hadden. Als je een nieuwe applicatie bouwt, maak passkeys je primaire inlogmethode en behandel wachtwoorden als de fallback.
De op-een-na-grootste verschuiving is dat het moeilijker wordt om je eigen auth te rechtvaardigen. Auth.js v5, Clerk en soortgelijke oplossingen handelen de moeilijke delen correct af. De enige reden om zelf te bouwen is wanneer je eisen oprecht niet passen in een bestaande oplossing — en dat is zeldzamer dan de meeste developers denken.
Wat je ook kiest, test je auth zoals een aanvaller dat zou doen. Probeer tokens opnieuw af te spelen, handtekeningen te vervalsen, routes te benaderen die je niet zou moeten, en redirect-URLs te manipuleren. De bugs die je vindt voor de lancering zijn de bugs die niet in het nieuws komen.