Auth.js v5 i Next.js App Router: Komplet Guide til Sikker Autentificering

Komplet guide til Auth.js v5 i Next.js App Router. Fra opsætning af OAuth og credentials til middleware, Data Access Layer, rollebaseret adgangskontrol og sikkerhedsmønstre – alt med TypeScript.

Introduktion til Auth.js v5 og Next.js App Router

Autentificering er noget, de fleste udviklere helst bare vil have til at virke – men som samtidig er en af de mest kritiske dele af enhver webapplikation. Uden en ordentlig autentificeringsløsning er dine brugerdata, server actions og API-ruter åbne for misbrug. Og ærligt talt, i 2026 er Auth.js v5 det oplagte valg, når du bygger med Next.js App Router.

Så lad os dykke ned i det.

Auth.js v5 er en komplet omskrivning af det tidligere NextAuth.js-bibliotek. Hvor NextAuth.js var tæt koblet til Next.js, er Auth.js designet som et framework-agnostisk autentificeringsbibliotek med adaptere til Next.js, SvelteKit, Express og flere. Det bedste ved v5? Den universelle auth()-funktion, som virker overalt: i Server Components, Server Actions, Route Handlers, middleware og API-ruter. Du behøver ikke længere jonglere mellem getServerSession(), getSession() og useSession() i forskellige kontekster. Det er faktisk en kæmpe forbedring.

Hvis du har fulgt vores tidligere artikel om databaseintegration med Drizzle ORM, bygger denne guide naturligt videre derfra. Vi viser, hvordan du kobler din autentificering til en database, beskytter dine server actions og implementerer rollebaseret adgangskontrol — alt sammen med TypeScript og moderne Next.js 15+ mønstre.

Opsætning af Auth.js v5

Installation

Start med at installere Auth.js v5 i dit Next.js-projekt. Pakken hedder stadig next-auth, men version 5 er den officielle Auth.js-implementering til Next.js:

npm install next-auth@5

Bruger du en databaseadapter (f.eks. Drizzle ORM), skal du også installere den relevante adapter:

npm install @auth/drizzle-adapter

Miljøvariabler

Opret en .env.local-fil i roden af dit projekt med følgende variabler:

# .env.local
AUTH_SECRET=din-hemmelige-noegle-generer-med-openssl-rand-base64-32
AUTH_URL=http://localhost:3000

# OAuth Providers
AUTH_GITHUB_ID=din-github-client-id
AUTH_GITHUB_SECRET=din-github-client-secret
AUTH_GOOGLE_ID=din-google-client-id
AUTH_GOOGLE_SECRET=din-google-client-secret

Du kan generere en sikker AUTH_SECRET med denne kommando:

npx auth secret

Auth.js v5 kræver, at AUTH_SECRET er sat i produktion. Uden den kan sessioner ikke krypteres – og så er dit system sårbart. Simpelt som det.

Split config-mønsteret

Her kommer en af de vigtigste arkitekturbeslutninger i Auth.js v5: det såkaldte split config-mønster. Grunden er, at Next.js middleware kører i Edge Runtime, som ikke understøtter alle Node.js-moduler. Hvis din auth-konfiguration bruger en databaseadapter (som Drizzle), kan den altså ikke køre direkte i Edge.

Løsningen? Split konfigurationen i to filer.

Opret først auth.config.ts — den del, der er kompatibel med Edge Runtime:

// 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";

export default {
  providers: [
    GitHub,
    Google,
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Adgangskode", type: "password" },
      },
      authorize: async (credentials) => {
        // Vi implementerer dette i næste afsnit
        return null;
      },
    }),
  ],
  pages: {
    signIn: "/login",
    error: "/auth/error",
  },
} satisfies NextAuthConfig;

Opret derefter den fulde auth.ts, som importerer config-filen og tilføjer databaseadapteren:

// auth.ts
import NextAuth from "next-auth";
import authConfig from "./auth.config";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  session: { strategy: "jwt" },
  ...authConfig,
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = 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;
    },
  },
});

Til sidst skal du oprette en Route Handler, der håndterer alle Auth.js-endpoints:

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

export const { GET, POST } = handlers;

Med denne opsætning har du en fuldt fungerende autentificeringsinfrastruktur. Middleware bruger den lette auth.config.ts, mens resten af din applikation bruger den fulde auth.ts med databaseadgang. Elegant, ikke?

Providers: OAuth og Credentials

OAuth-providers (GitHub og Google)

OAuth er den simpleste og mest sikre måde at autentificere brugere på. Auth.js v5 gør det næsten trivielt at tilføje OAuth-providers – og det er ikke en overdrivelse. GitHub og Google er de mest populære, men Auth.js understøtter over 80 providers ud af boksen.

For GitHub opretter du en OAuth App på github.com/settings/developers. Sæt callback-URL'en til http://localhost:3000/api/auth/callback/github under udvikling. For Google opretter du credentials i Google Cloud Console og tilføjer http://localhost:3000/api/auth/callback/google som autoriseret redirect-URI.

Auth.js v5 bruger automatisk miljøvariabler med navnekonventionen AUTH_[PROVIDER]_ID og AUTH_[PROVIDER]_SECRET, så du behøver ofte ikke engang at konfigurere dem eksplicit:

// Denne korte syntaks virker, fordi Auth.js læser
// AUTH_GITHUB_ID og AUTH_GITHUB_SECRET automatisk
providers: [
  GitHub,
  Google,
]

Det er virkelig så simpelt. Ingen lange konfigurationsobjekter nødvendige.

Credentials-provider med email og adgangskode

Credentials-provideren giver dig fuld kontrol over autentificeringslogikken. Du skal selv håndtere validering af email og adgangskode, og du bør altid bruge bcrypt eller argon2 til at hashe adgangskoder:

// auth.config.ts
import { z } from "zod";
import bcrypt from "bcryptjs";
import { getUserByEmail } from "@/db/queries/users";
import Credentials from "next-auth/providers/credentials";

const loginSchema = z.object({
  email: z.string().email("Ugyldig email-adresse"),
  password: z.string().min(8, "Adgangskode skal være mindst 8 tegn"),
});

export default {
  providers: [
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Adgangskode", type: "password" },
      },
      authorize: async (credentials) => {
        const parsed = loginSchema.safeParse(credentials);

        if (!parsed.success) {
          return null;
        }

        const { email, password } = parsed.data;
        const user = await getUserByEmail(email);

        if (!user || !user.hashedPassword) {
          return null;
        }

        const isPasswordValid = await bcrypt.compare(
          password,
          user.hashedPassword
        );

        if (!isPasswordValid) {
          return null;
        }

        return {
          id: user.id,
          name: user.name,
          email: user.email,
          role: user.role,
        };
      },
    }),
  ],
} satisfies NextAuthConfig;

Bemærk, at authorize-funktionen returnerer null ved fejl og et user-objekt ved succes. Du bør aldrig kaste fejl med specifikke beskeder som "forkert adgangskode" — det giver en angriber information om, hvilke email-adresser der eksisterer i systemet. Det er en klassisk sikkerhedsfejl, som stadig ses overraskende ofte.

Session-håndtering

JWT vs. databasesessioner

Auth.js v5 understøtter to session-strategier: JWT og database. Med JWT gemmes sessiondata i en krypteret cookie, hvilket er hurtigt og serverless-venligt. Med databasesessioner gemmes sessionen i din database, og cookien indeholder kun et session-token.

I de fleste Next.js App Router-projekter anbefaler vi JWT-strategien. Den fungerer problemfrit med Edge Runtime og kræver ikke et databaseopslag ved hvert request:

// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  session: { strategy: "jwt" },
  // ...
});

Session callbacks og TypeScript-udvidelser

Auth.js v5 har et stærkt callback-system, der lader dig tilpasse, hvad der gemmes i JWT-tokenet og sessionen. For at tilføje brugerdefinerede felter som role skal du udvide TypeScript-typerne:

// types/next-auth.d.ts
import { DefaultSession } from "next-auth";

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

  interface User {
    role: string;
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    id: string;
    role: string;
  }
}

Med disse typeudvidelser får du fuld autocompletion, når du tilgår session.user.role i din kode. Og det gør virkelig en forskel for developer experience.

React cache() til session-memoization

I Server Components kan du ende med at kalde auth() mange gange i samme request. For at undgå redundante JWT-dekrypteringer kan du bruge Reacts cache()-funktion:

// lib/session.ts
import { cache } from "react";
import { auth } from "@/auth";

export const getSession = cache(async () => {
  const session = await auth();
  return session;
});

Nu kan du kalde getSession() i flere Server Components inden for samme request, og sessionen dekrypteres kun én gang. Det er særligt vigtigt i layouts og nested components, hvor flere komponenter kan have brug for at tjekke autentificeringstilstanden.

Middleware og rutebeskyttelse

Grundlæggende middleware-opsætning

Middleware i Next.js kører før hver request og er ideel til at beskytte ruter. Auth.js v5 gør det nemt at integrere autentificering her:

// middleware.ts
import authConfig from "./auth.config";
import NextAuth from "next-auth";

const { auth } = NextAuth(authConfig);

const publicRoutes = ["/", "/login", "/register", "/auth/error"];
const authRoutes = ["/login", "/register"];

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

  const isPublicRoute = publicRoutes.includes(nextUrl.pathname);
  const isAuthRoute = authRoutes.includes(nextUrl.pathname);
  const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");

  // Tillad altid Auth.js API-ruter
  if (isApiAuthRoute) {
    return;
  }

  // Redirect logged-in brugere væk fra login/register
  if (isAuthRoute) {
    if (isLoggedIn) {
      return Response.redirect(new URL("/dashboard", nextUrl));
    }
    return;
  }

  // Kræv login for beskyttede ruter
  if (!isLoggedIn && !isPublicRoute) {
    const callbackUrl = encodeURIComponent(nextUrl.pathname + nextUrl.search);
    return Response.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, nextUrl)
    );
  }

  return;
});

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Bemærk, at vi bruger auth.config.ts (ikke auth.ts) i middleware, fordi middleware kører i Edge Runtime og ikke har adgang til Node.js-specifikke moduler som databasedrivere.

Den authorized callback

Auth.js v5 tilbyder også en authorized-callback som alternativ til manuel middleware-logik:

// auth.config.ts
export default {
  // ...providers
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");

      if (isOnDashboard) {
        return isLoggedIn; // false redirecter til login
      }

      return true; // Tillad adgang til alle andre ruter
    },
  },
} satisfies NextAuthConfig;

CVE-2025-29927: Hvorfor middleware alene ikke er nok

Her kommer et vigtigt punkt, som mange overser.

I marts 2025 blev en kritisk sårbarhed (CVE-2025-29927) opdaget i Next.js middleware. Sårbarheden tillod angribere at omgå middleware-autentificeringstjek ved at sende en specielt formateret x-middleware-subrequest-header. Det betød, at enhver rutebeskyttelse baseret udelukkende på middleware kunne omgås fuldstændigt.

Selvom sårbarheden er patchet i nyere versioner af Next.js (14.2.25+, 15.2.3+), understreger den et fundamentalt princip: stol aldrig udelukkende på middleware til sikkerhed. Middleware bør betragtes som et første forsvarslag — en optimistisk kontrol, der giver hurtig feedback. Den egentlige sikkerhed skal implementeres i dine Server Components, Server Actions og datalag.

Sørg for, at du kører en patchet version:

# Skal vise 15.2.3 eller nyere
npm list next

Og implementer altid autentificeringstjek i dit datalag, som vi gennemgår i næste afsnit.

Data Access Layer (DAL) mønsteret

Hvorfor DAL?

Next.js-teamet anbefaler et Data Access Layer-mønster, hvor al dataadgang centraliseres i et dedikeret lag, der altid verificerer brugerens session, før data returneres. Det giver dig defense in depth: selvom middleware skulle fejle eller omgås, er dine data stadig beskyttet.

Personligt synes jeg, at DAL-mønsteret er en af de bedste arkitekturpraksisser, man kan adoptere i et Next.js-projekt. Det tvinger dig til at tænke over sikkerhed på det rigtige sted.

verifySession-funktionen

Kernen i DAL-mønsteret er en verifySession-funktion, der tjekker sessionens gyldighed:

// lib/dal.ts
import "server-only";
import { cache } from "react";
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export const verifySession = cache(async () => {
  const session = await auth();

  if (!session?.user?.id) {
    redirect("/login");
  }

  return {
    isAuthenticated: true,
    userId: session.user.id,
    userRole: session.user.role,
  };
});

Bemærk import "server-only" øverst. Denne import sikrer, at filen aldrig importeres i klient-kode. Hvis nogen ved en fejl prøver at importere den i en Client Component, fejler buildet med det samme. Ret smart.

Data Transfer Objects (DTO'er)

Et andet vigtigt princip er at bruge DTO'er til at kontrollere, hvilke data der sendes til klienten. Du bør aldrig returnere hele databaseobjekter, fordi de kan indeholde følsomme felter som hashede adgangskoder:

// lib/dal.ts
import "server-only";
import { cache } from "react";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";

export const verifySession = cache(async () => {
  const session = await auth();

  if (!session?.user?.id) {
    redirect("/login");
  }

  return {
    isAuthenticated: true,
    userId: session.user.id,
    userRole: session.user.role,
  };
});

export const getCurrentUser = cache(async () => {
  const session = await verifySession();

  const user = await db.query.users.findFirst({
    where: eq(users.id, session.userId),
    columns: {
      id: true,
      name: true,
      email: true,
      role: true,
      createdAt: true,
      // Bemærk: hashedPassword er IKKE inkluderet
    },
  });

  if (!user) {
    redirect("/login");
  }

  return user;
});

// DTO-funktion til offentlig brugerprofil
export function toPublicUser(user: {
  id: string;
  name: string | null;
  email: string;
  role: string;
}) {
  return {
    id: user.id,
    name: user.name,
    role: user.role,
    // Email er bevidst udeladt fra den offentlige profil
  };
}

Med dette mønster er dine dataforespørgsler altid beskyttet, uanset hvorfra de kaldes. Hvis sessionen er ugyldig, redirectes brugeren. Og DTO'er sikrer, at følsomme data aldrig lækker til klienten.

Server Actions med autentificering

Beskyttelse af Server Actions

Server Actions er funktioner, der kører på serveren, men som kan kaldes direkte fra klienten. Det gør dem til potentielle angrebsflader — og de skal derfor altid validere brugerens session og input. Ingen undtagelser.

// app/actions/posts.ts
"use server";

import { z } from "zod";
import { auth } from "@/auth";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { verifySession } from "@/lib/dal";

const createPostSchema = z.object({
  title: z.string().min(3, "Titlen skal være mindst 3 tegn").max(200),
  content: z.string().min(10, "Indholdet skal være mindst 10 tegn").max(10000),
  published: z.boolean().default(false),
});

export type CreatePostState = {
  errors?: {
    title?: string[];
    content?: string[];
    published?: string[];
    _form?: string[];
  };
  success?: boolean;
};

export async function createPost(
  prevState: CreatePostState,
  formData: FormData
): Promise<CreatePostState> {
  // 1. Verificer session
  const session = await verifySession();

  // 2. Valider input med Zod
  const parsed = createPostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
    published: formData.get("published") === "on",
  });

  if (!parsed.success) {
    return {
      errors: parsed.error.flatten().fieldErrors,
    };
  }

  // 3. Udfør mutation
  try {
    await db.insert(posts).values({
      title: parsed.data.title,
      content: parsed.data.content,
      published: parsed.data.published,
      authorId: session.userId,
    });
  } catch (error) {
    return {
      errors: {
        _form: ["Der opstod en fejl. Prøv venligst igen."],
      },
    };
  }

  // 4. Revalider cache
  revalidatePath("/dashboard/posts");

  return { success: true };
}

CSRF-beskyttelse

Next.js 15+ har indbygget CSRF-beskyttelse for Server Actions. Frameworket genererer automatisk et unikt token og verificerer det på serversiden. Du behøver ikke implementere det manuelt for Server Actions, men du skal være opmærksom på det i custom API-ruter.

For ekstra sikkerhed kan du tilføje en Origin-header-kontrol:

// app/api/data/route.ts
import { auth } from "@/auth";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  // Tjek Origin header
  const origin = request.headers.get("origin");
  const allowedOrigin = process.env.NEXT_PUBLIC_APP_URL;

  if (origin !== allowedOrigin) {
    return NextResponse.json(
      { error: "Uautoriseret oprindelse" },
      { status: 403 }
    );
  }

  // Verificer session
  const session = await auth();

  if (!session?.user) {
    return NextResponse.json(
      { error: "Ikke autentificeret" },
      { status: 401 }
    );
  }

  // Håndter forespørgslen...
  return NextResponse.json({ success: true });
}

Server Action med useActionState

I React 19 og Next.js 15+ bruges useActionState (det der tidligere hed useFormState) til at håndtere formularens tilstand. Her er et komplet eksempel på en login-formular:

// app/login/page.tsx
"use client";

import { useActionState } from "react";
import { login } from "@/app/actions/auth";

export default function LoginPage() {
  const [state, formAction, isPending] = useActionState(login, {
    errors: {},
  });

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full border rounded px-3 py-2"
        />
        {state.errors?.email && (
          <p className="text-red-500 text-sm">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Adgangskode</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          className="w-full border rounded px-3 py-2"
        />
        {state.errors?.password && (
          <p className="text-red-500 text-sm">{state.errors.password[0]}</p>
        )}
      </div>

      {state.errors?._form && (
        <p className="text-red-500">{state.errors._form[0]}</p>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-blue-600 text-white py-2 rounded"
      >
        {isPending ? "Logger ind..." : "Log ind"}
      </button>
    </form>
  );
}
// app/actions/auth.ts
"use server";

import { z } from "zod";
import { signIn } from "@/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";

const loginSchema = z.object({
  email: z.string().email("Ugyldig email-adresse"),
  password: z.string().min(1, "Adgangskode er påkrævet"),
});

export type LoginState = {
  errors?: {
    email?: string[];
    password?: string[];
    _form?: string[];
  };
};

export async function login(
  prevState: LoginState,
  formData: FormData
): Promise<LoginState> {
  const parsed = loginSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }

  try {
    await signIn("credentials", {
      email: parsed.data.email,
      password: parsed.data.password,
      redirect: false,
    });
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case "CredentialsSignin":
          return {
            errors: { _form: ["Ugyldig email eller adgangskode."] },
          };
        default:
          return {
            errors: { _form: ["Noget gik galt. Prøv igen senere."] },
          };
      }
    }
    throw error;
  }

  redirect("/dashboard");
}

Rollebaseret adgangskontrol (RBAC)

Udvidelse af Auth.js-typer for roller

Vi har allerede udvidet TypeScript-typerne i et tidligere afsnit. Lad os nu se, hvordan roller bruges i praksis. Først skal rollen gemmes i databasen. Med Drizzle ORM kan dit brugerskema se sådan ud:

// db/schema.ts
import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core";

export const roleEnum = pgEnum("role", ["user", "admin", "moderator"]);

export const users = pgTable("users", {
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
  name: text("name"),
  email: text("email").notNull().unique(),
  hashedPassword: text("hashed_password"),
  role: roleEnum("role").notNull().default("user"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Middleware-niveau rolletjek

Du kan tilføje rollebaserede tjek direkte i middleware, men husk — dette er kun et optimistisk førstelag:

// middleware.ts
import authConfig from "./auth.config";
import NextAuth from "next-auth";

const { auth } = NextAuth(authConfig);

const adminRoutes = ["/admin", "/admin/users", "/admin/settings"];

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

  // Admin-ruter kræver admin-rolle
  if (adminRoutes.some((route) => nextUrl.pathname.startsWith(route))) {
    if (!isLoggedIn || userRole !== "admin") {
      return Response.redirect(new URL("/unauthorized", nextUrl));
    }
  }

  // ... øvrig middleware-logik
});

Komponent-niveau rolletjek

For mere granulær kontrol kan du oprette hjælpefunktioner og komponenter til rollebaseret rendering. Det er her, det virkelig bliver brugbart i praksis:

// lib/authorization.ts
import "server-only";
import { verifySession } from "@/lib/dal";
import { redirect } from "next/navigation";

export async function requireRole(requiredRole: string) {
  const session = await verifySession();

  if (session.userRole !== requiredRole) {
    redirect("/unauthorized");
  }

  return session;
}

export async function requireAnyRole(roles: string[]) {
  const session = await verifySession();

  if (!roles.includes(session.userRole)) {
    redirect("/unauthorized");
  }

  return session;
}
// components/role-gate.tsx
import { auth } from "@/auth";

interface RoleGateProps {
  allowedRoles: string[];
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export async function RoleGate({
  allowedRoles,
  children,
  fallback = null,
}: RoleGateProps) {
  const session = await auth();
  const userRole = session?.user?.role;

  if (!userRole || !allowedRoles.includes(userRole)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

Brug RoleGate-komponenten i dine Server Components:

// app/dashboard/page.tsx
import { RoleGate } from "@/components/role-gate";
import { AdminPanel } from "@/components/admin-panel";

export default async function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      <RoleGate allowedRoles={["admin", "moderator"]}>
        <AdminPanel />
      </RoleGate>

      <RoleGate
        allowedRoles={["admin"]}
        fallback={<p>Du har ikke adgang til denne sektion.</p>}
      >
        <section>
          <h2>Brugeradministration</h2>
        </section>
      </RoleGate>
    </div>
  );
}

Beskyttelse af API-ruter med rolle

// app/api/admin/users/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import { db } from "@/db";
import { users } from "@/db/schema";

export async function GET() {
  const session = await auth();

  if (!session?.user) {
    return NextResponse.json(
      { error: "Ikke autentificeret" },
      { status: 401 }
    );
  }

  if (session.user.role !== "admin") {
    return NextResponse.json(
      { error: "Ikke autoriseret" },
      { status: 403 }
    );
  }

  const allUsers = await db
    .select({
      id: users.id,
      name: users.name,
      email: users.email,
      role: users.role,
      createdAt: users.createdAt,
    })
    .from(users);

  return NextResponse.json({ users: allUsers });
}

Beskyttelse af Server Components

Brug af auth() i Server Components

En af de største fordele ved Auth.js v5 er, at auth()-funktionen kan bruges direkte i Server Components. Ingen hooks, ingen kontekster — bare kald funktionen:

// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();

  if (!session?.user) {
    redirect("/login");
  }

  return (
    <div>
      <h1>Velkommen, {session.user.name}</h1>
      <p>Din rolle: {session.user.role}</p>
    </div>
  );
}

Betinget rendering baseret på autentificering

I layouts og navigation kan du bruge sessionstilstanden til at vise forskellige elementer:

// components/navbar.tsx
import { auth } from "@/auth";
import { SignOutButton } from "@/components/sign-out-button";
import Link from "next/link";

export async function Navbar() {
  const session = await auth();

  return (
    <nav className="flex items-center justify-between p-4 border-b">
      <Link href="/" className="font-bold text-xl">
        MinApp
      </Link>

      <div className="flex items-center gap-4">
        {session?.user ? (
          <>
            <span>{session.user.email}</span>
            <Link href="/dashboard">Dashboard</Link>
            {session.user.role === "admin" && (
              <Link href="/admin">Admin</Link>
            )}
            <SignOutButton />
          </>
        ) : (
          <>
            <Link href="/login">Log ind</Link>
            <Link href="/register">Opret konto</Link>
          </>
        )}
      </div>
    </nav>
  );
}
// components/sign-out-button.tsx
"use client";

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

export function SignOutButton() {
  return (
    <button
      onClick={() => signOut({ callbackUrl: "/" })}
      className="text-red-600 hover:underline"
    >
      Log ud
    </button>
  );
}

Redirect-mønstre

Der er to hovedmønstre for at håndtere uautoriserede brugere i Server Components:

  1. Redirect til login: Brug redirect("/login") fra next/navigation. Inkluder gerne en callbackUrl, så brugeren sendes tilbage efter login.
  2. Vis en 404 eller 403: Brug notFound() fra next/navigation. Det er nyttigt, når du ikke vil afsløre, at en ressource overhovedet eksisterer.
// app/posts/[id]/edit/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { EditPostForm } from "@/components/edit-post-form";

interface Props {
  params: Promise<{ id: string }>;
}

export default async function EditPostPage({ params }: Props) {
  const { id } = await params;
  const session = await auth();

  if (!session?.user) {
    redirect("/login");
  }

  const post = await db.query.posts.findFirst({
    where: eq(posts.id, id),
  });

  if (!post) {
    notFound();
  }

  // Kun forfatteren eller admins kan redigere
  if (post.authorId !== session.user.id && session.user.role !== "admin") {
    notFound();
  }

  return <EditPostForm post={post} />;
}

Sikkerhedsovervejelser og best practices

Defense in depth

Det vigtigste princip inden for applikationssikkerhed er defense in depth — forsvar i dybden. Stol aldrig på et enkelt lag af beskyttelse. Her er de lag, du bør implementere:

  1. Middleware — Optimistisk rutebeskyttelse og hurtig feedback til brugeren.
  2. Data Access Layer — Verificer session ved alle dataforespørgsler. Det her er dit primære sikkerhedslag.
  3. Server Actions — Tjek session og valider input med Zod før enhver mutation.
  4. API Routes — Verificer session og autorisering i starten af hver route handler.
  5. Database-niveau — Brug Row Level Security (RLS) hvis din database understøtter det (f.eks. PostgreSQL).

Cookie-sikkerhed

Auth.js v5 sætter automatisk sikre cookie-indstillinger i produktion, men det er godt at forstå, hvad de gør:

  • HttpOnly: Forhindrer JavaScript i at læse cookien, hvilket beskytter mod XSS-angreb.
  • Secure: Sikrer, at cookien kun sendes over HTTPS.
  • SameSite=Lax: Forhindrer cookien i at blive sendt med cross-site requests, hvilket beskytter mod CSRF.

Du kan tilpasse cookie-indstillingerne, men standardindstillingerne er generelt tilstrækkelige:

// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
  // ...
  cookies: {
    sessionToken: {
      name: "__Secure-authjs.session-token",
      options: {
        httpOnly: true,
        sameSite: "lax",
        path: "/",
        secure: process.env.NODE_ENV === "production",
      },
    },
  },
});

Rate limiting

For at beskytte dine login-endpoints mod brute force-angreb bør du implementere rate limiting. Et bibliotek som @upstash/ratelimit med Redis gør det overraskende nemt:

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const loginRateLimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  analytics: true,
  prefix: "ratelimit:login",
});
// app/actions/auth.ts
"use server";

import { loginRateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

export async function login(prevState: LoginState, formData: FormData) {
  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for") ?? "unknown";

  const { success, remaining } = await loginRateLimit.limit(ip);

  if (!success) {
    return {
      errors: {
        _form: [
          "For mange loginforsøg. Vent venligst et minut, før du prøver igen.",
        ],
      },
    };
  }

  // ... resten af login-logikken
}

Yderligere sikkerhedstips

  • Log aldrig følsomme data: Undgå at logge sessions-tokens, adgangskoder eller andre følsomme oplysninger. Det lyder oplagt, men det sker oftere end man tror.
  • Brug environment variables korrekt: Hold AUTH_SECRET og andre hemmeligheder i .env.local, og commit aldrig denne fil.
  • Implementer session-udløb: Sæt en passende maxAge for dine JWT-tokens. Standard er 30 dage, men overvej kortere perioder for applikationer med høje sikkerhedskrav.
  • Overvej to-faktor-autentificering: For kritiske applikationer bør du tilføje 2FA som et ekstra lag.
  • Sanitizer altid brugerinput: Brug Zod til at validere al input i Server Actions og API-ruter.
// auth.ts - Eksempel på session-udløb
export const { handlers, auth, signIn, signOut } = NextAuth({
  // ...
  session: {
    strategy: "jwt",
    maxAge: 7 * 24 * 60 * 60, // 7 dage
    updateAge: 24 * 60 * 60,  // Opdater session hver 24. time
  },
  jwt: {
    maxAge: 7 * 24 * 60 * 60,
  },
});

Et komplet eksempel: Beskyttet dashboard med RBAC

Lad os samle alle koncepterne i et komplet eksempel. Her er et beskyttet dashboard med rollebaseret adgangskontrol, der bruger alle de mønstre vi har gennemgået:

// app/dashboard/layout.tsx
import { verifySession } from "@/lib/dal";
import { DashboardSidebar } from "@/components/dashboard-sidebar";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await verifySession();

  return (
    <div className="flex min-h-screen">
      <DashboardSidebar userRole={session.userRole} />
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}
// components/dashboard-sidebar.tsx
import Link from "next/link";

interface DashboardSidebarProps {
  userRole: string;
}

const menuItems = [
  { href: "/dashboard", label: "Oversigt", roles: ["user", "admin", "moderator"] },
  { href: "/dashboard/posts", label: "Mine indlæg", roles: ["user", "admin", "moderator"] },
  { href: "/dashboard/settings", label: "Indstillinger", roles: ["user", "admin", "moderator"] },
  { href: "/admin/users", label: "Brugere", roles: ["admin"] },
  { href: "/admin/analytics", label: "Analyse", roles: ["admin", "moderator"] },
  { href: "/admin/settings", label: "Systemindstillinger", roles: ["admin"] },
];

export function DashboardSidebar({ userRole }: DashboardSidebarProps) {
  const visibleItems = menuItems.filter((item) =>
    item.roles.includes(userRole)
  );

  return (
    <aside className="w-64 border-r bg-gray-50 p-4">
      <nav>
        <ul className="space-y-2">
          {visibleItems.map((item) => (
            <li key={item.href}>
              <Link
                href={item.href}
                className="block px-3 py-2 rounded hover:bg-gray-200"
              >
                {item.label}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
    </aside>
  );
}
// app/admin/users/page.tsx
import { requireRole } from "@/lib/authorization";
import { db } from "@/db";
import { users } from "@/db/schema";

export default async function AdminUsersPage() {
  await requireRole("admin");

  const allUsers = await db
    .select({
      id: users.id,
      name: users.name,
      email: users.email,
      role: users.role,
      createdAt: users.createdAt,
    })
    .from(users)
    .orderBy(users.createdAt);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Brugeradministration</h1>

      <table className="w-full border-collapse">
        <thead>
          <tr className="border-b">
            <th className="text-left p-3">Navn</th>
            <th className="text-left p-3">Email</th>
            <th className="text-left p-3">Rolle</th>
            <th className="text-left p-3">Oprettet</th>
          </tr>
        </thead>
        <tbody>
          {allUsers.map((user) => (
            <tr key={user.id} className="border-b">
              <td className="p-3">{user.name}</td>
              <td className="p-3">{user.email}</td>
              <td className="p-3">
                <span className="px-2 py-1 rounded bg-blue-100 text-blue-800 text-sm">
                  {user.role}
                </span>
              </td>
              <td className="p-3">
                {user.createdAt.toLocaleDateString("da-DK")}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Konklusion

Auth.js v5 giver dig en robust og fleksibel autentificeringsløsning til Next.js App Router. Den universelle auth()-funktion gør det simpelt at tjekke sessionstilstanden overalt i din applikation, og split config-mønsteret sikrer kompatibilitet med Edge Runtime.

Her er de vigtigste pointer:

  • Brug split config-mønsteret med auth.config.ts til Edge Runtime (middleware) og auth.ts til den fulde konfiguration med databaseadapter.
  • Implementer defense in depth — stol aldrig udelukkende på middleware. Verificer altid sessionen i dit Data Access Layer, dine Server Actions og dine API-ruter.
  • Brug DAL-mønsteret med en centraliseret verifySession()-funktion og DTO'er for at sikre, at følsomme data aldrig lækker til klienten.
  • Valider altid input med Zod i Server Actions, og husk at Next.js 15+ giver dig automatisk CSRF-beskyttelse.
  • Implementer rollebaseret adgangskontrol på flere niveauer: middleware, Server Components og Server Actions.
  • Hold dine dependencies opdateret og følg med i sikkerhedsmeddelelser. CVE-2025-29927 viste os hvorfor.
  • Brug React cache() til at memoize sessionsopslag og undgå unødvendige JWT-dekrypteringer.

Denne guide komplementerer vores artikel om databaseintegration med Drizzle ORM. Hvor Drizzle-artiklen viser, hvordan du opsætter din database, skemaer og migrationer, har denne artikel vist, hvordan du beskytter adgangen til disse data. Sammen giver de to artikler dig et solidt fundament til sikre, produktionsklare Next.js-applikationer.

Næste skridt herfra kunne være at implementere email-verifikation, password-reset flows, to-faktor-autentificering eller integration med en ekstern identitetsudbyder. Auth.js v5 understøtter alle disse scenarier, og den modulære arkitektur gør det nemt at udvide din opsætning efterhånden som kravene vokser.

God fornøjelse med at bygge sikre applikationer – og husk, sikkerhed er en rejse, ikke en destination.

Om Forfatteren Editorial Team

Our team of expert writers and editors.