John Aleman

Authentication & Authorization Architecture

Federated sign-in with Auth.js v5 — providers, credentials, JWT sessions and route protection — session lifecycles and audit trails, and why authorization has to be enforced on the server, close to the data.

View the live demo →
t: "overview"

Concept: security is architecture

Authentication and authorization are foundations, not route checks

A production Next.js application runs code across several boundaries, and each one carries its own security responsibilities: Client Components in the browser, Server Components, Server Actions, Route Handlers, the proxy (formerly middleware) in front of them, and the database and external services behind them. The core argument is that authentication and authorization have to be designed into all of these as foundations, rather than bolted on as a superficial check at the route. The strategy is defense in depth: protect routes, verify sessions, check permissions, safeguard secrets, and enforce authorization close to the sensitive operation.

Authentication compared with authorization
ConcernQuestionExamplesFails with
Authentication (AuthN)Who is this user?Signing in with Google or GitHub; verifying an email and password; establishing a session401, or a redirect to sign-in
Authorization (AuthZ)What may this authenticated user do?Opening an admin dashboard; editing a resource; viewing one account's data; actions restricted to a role403, or 404 to hide that the resource exists

Authentication comes first, because most user-specific decisions need to know who is asking. But it never grants unrestricted access: a verified identity is an input to authorization, not a substitute for it. Keeping the two separate also keeps each replaceable, so adding a provider doesn't touch a permission check and adding a role doesn't touch the sign-in flow.

Like a hotel: the front desk checks your passport and hands you a key card. Which doors the card opens is a different system, and it should be checked at every door.

t: "federated"

Concept: federated authentication

Relying on a trusted identity provider

Federated authentication lets an application rely on an external identity provider (IdP) rather than collecting a new password for every product. The examples below use Google OAuth, GitHub OAuth and a custom credentials flow side by side. In each case the application still owns two things: its own session, and its own authorization rules.

OAuth 2.0 is primarily an authorization framework: it issues an access token so a client can call an API on the user's behalf. OpenID Connect (OIDC) adds standardized identity authentication on top: the openid scope yields a signed ID token describing who signed in. An OAuth access token is not proof of identity. Google is an OIDC provider; GitHub's sign-in is plain OAuth 2.0, so a library reads the profile from GitHub's API instead of an ID token.

fig. 1 — the provider verifies identity (steps 3–9); permission enforcement is not part of this flow.
  1. User to Next.js: Click “Sign in with Google”. The user starts sign-in from the Next.js app.
  2. Next.js to Auth library: signIn('google'). A Server Action or route calls the authentication library.
  3. Auth library to Google / GitHub: 302: state + code_challenge. The library redirects the browser to the provider with a random state, a PKCE challenge and the registered redirect URI.
  4. Google / GitHub to User: User signs in and consents. The provider authenticates the user; the app never sees a password.
  5. Google / GitHub to Next.js: Callback ?code & state. The provider redirects to the exact redirect URI registered for this client, carrying a one-time authorization code.
  6. Next.js to Auth library: Verify state, pass code. The library checks the returned state against its cookie.
  7. Auth library to Google / GitHub: code + code_verifier. Back-channel request: the provider proves the verifier matches the earlier challenge.
  8. Google / GitHub to Auth library: ID token + access token. The ID token says who signed in; the access token is for calling the provider's API.
  9. Auth library to App session: Link account, create session. The library validates the ID token, finds or creates the user, and stores the session.
  10. Next.js to User: 302 to app + Set-Cookie. The browser returns to the app holding an HttpOnly session cookie.

Additional context. The protections worth being able to name in that diagram:

  • Authorization Code flow: the browser carries only a short-lived, single-use code; tokens are exchanged on a back channel.
  • Redirect URI validation: the provider redirects only to a pre-registered URI, compared exactly.
  • State and PKCE: state stops an attacker finishing their login in your browser; PKCE makes an intercepted code useless on its own. Libraries enable them where the provider supports them.
  • ID token validation: signature, issuer, audience, expiry and nonce. A library does this for you.

Identity verification ends when the application session exists. Permission enforcement begins on the next request, and the provider has no part in it.

t: "authjs-config"

Concept: centralized Auth.js configuration

One auth.ts that every server context imports

This pattern uses Auth.js v5, the successor to NextAuth.js, and keeps all configuration in a single auth.ts. It imports NextAuth and the Google, GitHub and Credentials providers, then exports four things:

// auth.ts
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";

import { db } from "@/lib/db";

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  session: { strategy: "jwt" },
  pages: { signIn: "/auth/signin" },
  providers: [Google, GitHub, Credentials({ /* see "credentials" below */ })],
  callbacks: { /* jwt and session, see "JWT sessions" below */ },
});
  • handlers — the GET and POST HTTP handlers for the Auth.js endpoints (sign-in, callbacks, session).
  • auth — reads and validates the session in supported server contexts.
  • signIn — starts a flow for a configured provider.
  • signOut — ends the application session.

Because the configuration is centralized, Server Components, Server Actions and Route Handlers all call the same auth and never reimplement session parsing.

The database adapter is @auth/drizzle-adapter over Neon. As of 2026-10-17 Auth.js v5 is still published under the beta npm tag (5.0.0-beta.32), and the project is now stewarded by the Better Auth team, who say existing users can keep using it and that security fixes continue. See the installation guide and Drizzle adapter. Nothing here is installed in this portfolio; the code is reference.

t: "providers"

Concept: multi-provider sign-in

Google and GitHub as identity providers

Registering a provider is the same recipe every time:

  1. Register the application with the provider (a Google Cloud OAuth client, a GitHub OAuth app).
  2. The provider issues a client ID and, where applicable, a client secret.
  3. Configure the authorized callback URL — for Auth.js in Next.js, https://your-domain/api/auth/callback/google (and /github).
  4. Auth.js starts the provider flow when the user chooses it.
  5. The provider redirects back to the callback URL.
  6. Auth.js processes the callback and establishes the application session.

Credentials can be passed explicitly. Conceptually:

providers: [
  Google({
    clientId: process.env.AUTH_GOOGLE_ID,
    clientSecret: process.env.AUTH_GOOGLE_SECRET,
  }),
  GitHub({
    clientId: process.env.AUTH_GITHUB_ID,
    clientSecret: process.env.AUTH_GITHUB_SECRET,
  }),
]

Current Auth.js documents the shorter form providers: [Google, GitHub], which reads AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET and the GitHub equivalents from the environment, plus a required AUTH_SECRET for encrypting tokens (generate it with npx auth secret). Either way the values live in server-side environment variables and never reach browser JavaScript. See the Google provider page.

t: "credentials"

Concept: credentials authentication

When the application verifies the password itself

Unlike the federated providers, the Credentials provider makes the application responsible for validating the user. Its authorize() callback receives the submitted email and password, checks they are present, loads the matching user, checks the user has a stored password, compares the two, and returns a user object on success (id, email, name, role) or null on failure.

A common first example compares user.password === credentials.password. It is only a placeholder: it implies plaintext passwords at rest and must not be copied. The version below stores an Argon2id hash, verifies against it, and does equal work whether or not the account exists:

import { verify } from "@node-rs/argon2";
import { eq } from "drizzle-orm";
import Credentials from "next-auth/providers/credentials";
import * as z from "zod";

import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";

const credentialsSchema = z.object({
  email: z.email(),
  password: z.string().min(1).max(128),
});

/* An Argon2id hash of a random string, so unknown emails cost the same. */
const DUMMY_HASH = process.env.AUTH_DUMMY_PASSWORD_HASH ?? "";

Credentials({
  credentials: { email: {}, password: {} },
  authorize: async (raw) => {
    const parsed = credentialsSchema.safeParse(raw);
    if (!parsed.success) return null;

    const [user] = await db
      .select({
        id: users.id,
        email: users.email,
        name: users.name,
        role: users.role,
        passwordHash: users.passwordHash,
      })
      .from(users)
      .where(eq(users.email, parsed.data.email.toLowerCase()))
      .limit(1);

    const matches = await verify(
      user?.passwordHash ?? DUMMY_HASH,
      parsed.data.password,
    );
    if (!user?.passwordHash || !matches) return null;

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

@node-rs/argon2 is one maintained Argon2 binding for Node (its default algorithm is Argon2id); use whichever your team has reviewed, with the parameters from the OWASP password storage guide. Auth.js's own docs recommend a more modern mechanism where possible (OAuth, magic links, WebAuthn) because Credentials persists nothing: hashing, rate limiting and reset flows are yours. A failed authorize() should give the same answer for “no such user” and “wrong password”.

t: "jwt-sessions"

Concept: JWT sessions and callbacks

Putting the user's id and role where application code can read them

Set session: { strategy: "jwt" }. The session then lives in a protected cookie that Auth.js reads on each request, instead of a database row it must look up. In Auth.js that token is encrypted rather than merely signed, but the exact encoding is the library's business; treat it as opaque. The Credentials provider is used with this strategy.

Two callbacks shape what the application sees:

callbacks: {
  /* Customizes the persisted token. `user` is only present at sign-in. */
  jwt({ token, user }) {
    if (user) {
      token.id = user.id;
      token.role = user.role;
    }
    return token;
  },

  /* Controls what is exposed through the session object. */
  session({ session, token }) {
    if (session.user) {
      session.user.id = token.id;
      session.user.role = token.role;
    }
    return session;
  },
},

Under strict TypeScript the callbacks above only compile once Auth.js's types know about the extra fields, which is done by module augmentation in a file your tsconfig.json includes:

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

type AppRole = "admin" | "user";

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

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

With these types, token.id is string | undefined, so the session callback needs a guard or a narrowing assertion; the Auth.js TypeScript guide shows the pattern. Note too that user in the jwt callback comes from authorize() or the provider profile, so an OAuth user needs the role filled in there as well (for example from the adapter's user row).

Production clarification: a role copied into a long-lived token goes stale. If an admin is demoted, the old token keeps saying admin until it expires or the user signs in again. Use the token for identity and coarse hints; make sensitive decisions against fresh data.

t: "sign-in"

Concept: route handler and custom sign-in page

Replacing the default Auth.js UI without crossing the client boundary

The route handler is two lines, because the configuration is already central. It serves the sign-in endpoints and the provider callbacks:

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

export const { GET, POST } = handlers;

pages: { signIn: "/auth/signin" } replaces the default sign-in screen. A typical page offers an email field, a password field, a credentials button, and Google and GitHub buttons, redirecting to /dashboard on success. The two kinds of login differ in who does the work: with credentials the application receives and verifies the secret server-side; with OAuth it redirects the browser to an external provider.

A Server Component can't carry client event handlers, so the OAuth buttons are forms posting to Server Actions, and the credentials form is the one interactive piece and becomes a small Client Component:

// app/auth/signin/page.tsx — a Server Component
import { signIn } from "@/auth";

import { CredentialsForm } from "./credentials-form";

export default function SignInPage() {
  return (
    <main>
      <h1>Sign in</h1>
      <CredentialsForm />

      <form
        action={async () => {
          "use server";
          await signIn("google", { redirectTo: "/dashboard" });
        }}
      >
        <button type="submit">Continue with Google</button>
      </form>

      <form
        action={async () => {
          "use server";
          await signIn("github", { redirectTo: "/dashboard" });
        }}
      >
        <button type="submit">Continue with GitHub</button>
      </form>
    </main>
  );
}
// app/auth/signin/actions.ts
"use server";

import { AuthError } from "next-auth";

import { signIn } from "@/auth";

export async function authenticate(
  _previous: string | undefined,
  formData: FormData,
) {
  try {
    await signIn("credentials", {
      email: String(formData.get("email") ?? ""),
      password: String(formData.get("password") ?? ""),
      redirectTo: "/dashboard",
    });
  } catch (error) {
    if (error instanceof AuthError) {
      return error.type === "CredentialsSignin"
        ? "Invalid email or password."
        : "Something went wrong. Please try again.";
    }
    throw error; // a successful sign-in redirects by throwing
  }
}

// app/auth/signin/credentials-form.tsx
"use client";

import { useActionState } from "react";

import { authenticate } from "./actions";

export function CredentialsForm() {
  const [message, formAction, pending] = useActionState(authenticate, undefined);

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" required />
      <button type="submit" disabled={pending}>Sign in</button>
      {message && <p role="alert">{message}</p>}
    </form>
  );
}

Verify redirectTo and the AuthError import against the Auth.js version you install; v5 renamed the older callbackUrl option. See signing in and out.

t: "route-protection"

Concept: route protection

Keeping visitors out of the dashboard, and signed-in users off the sign-in page

The proxy wraps the request in Auth.js's auth function in middleware.ts and matches /dashboard/:path* and /auth/:path*. An unauthenticated request for the dashboard is redirected to sign-in, a signed-in request for the sign-in pages is redirected to the dashboard, and everything else stays public. That keeps navigation honest and stops anonymous visitors reaching protected pages.

Next.js 16 renamed Middleware to Proxy: the file is proxy.ts and the export is proxy. Auth.js documents auth as usable there directly, and the installed Next.js docs confirm Proxy defaults to the Node.js runtime.

// proxy.ts  (middleware.ts with `export const middleware` before Next.js 16)
import { NextResponse } from "next/server";

import { auth } from "@/auth";

export const proxy = auth((req) => {
  const { pathname } = req.nextUrl;
  const signedIn = Boolean(req.auth);

  if (pathname.startsWith("/dashboard") && !signedIn) {
    return NextResponse.redirect(new URL("/auth/signin", req.nextUrl));
  }
  if (pathname.startsWith("/auth") && signedIn) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
  return NextResponse.next();
});

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

This is a navigation convenience, not an access control. A Server Action is a POST that never passes through your pages, a Route Handler can be called by any client, and the proxy rarely sees the row being read. Authorization still has to be enforced again inside Server Actions, Route Handlers and the data-access layer. Auth.js's own docs say the same: verify the session as close to the data fetch as possible.

t: "server-component"

Concept: a protected Server Component

Reading the session where the page renders

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

import { auth } from "@/auth";

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

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

  return (
    <main>
      <h1>Dashboard</h1>
      <p>Welcome, {session.user.name}</p>
      <p>Role: {session.user.role}</p>
    </main>
  );
}
  • A Server Component reads authentication state directly on the server, with no client round trip.
  • An unauthenticated user is redirected before any protected content renders.
  • The session personalizes the page — name, role.
  • Seeing the page is not the same as being allowed to read every record on it: sensitive data still needs authorization at the data-access boundary, covered below.

With Cache Components enabled, the session read is request-time work, so authenticated UI streams in behind Suspense; see the Next.js guide to authentication with Cache Components.

t: "production"

Concept: production security

What to do before shipping

  • Environment secrets: OAuth credentials, database credentials and AUTH_SECRET stay out of source control and out of client code.
  • OAuth redirect validation: register exact callback URLs, and allowlist any post-login return path to same-origin routes.
  • Password hashing: never store plaintext; use an established algorithm, preferably Argon2id.
  • Rate limiting: on credentials sign-in and other abuse-sensitive endpoints, per account and per address.
  • Secret rotation: plan how to rotate provider secrets, database credentials and the session secret without exposing them or locking everyone out.
  • Authentication logging: record sign-ins, failures, linking and role changes, but never tokens or passwords.
  • Defense in depth: protect the route, then enforce access again at the server-side operation and the data-access layer.

Additional context. Cookie flags (Secure, HttpOnly, SameSite) and HTTPS with HSTS, Origin checks on any custom cookie-authenticated Route Handler, session expiry and a tested revoke path, and tests that call every action and route unauthenticated and unauthorized, asserting nothing changed. A library removes whole bug classes; it does not write your policy or test your mutations.

t: "rbac"

Concept: RBAC and fine-grained authorization

From roles to resource and tenant checks, enforced on the server

Role-based access control assigns permissions to roles, and roles to users, instead of granting every permission to every user:

User
  |
  +-- Role: Admin
  |     +-- users:read
  |     +-- users:write
  |     +-- reports:read
  |
  +-- Role: Viewer
        +-- reports:read

Least privilege says a user gets only what the job needs. Signing in with Google or GitHub is an identity fact, so it never confers elevated application privileges: new accounts start with the least-powerful role.

Fine-grained authorization goes beyond a role check. The decision can depend on the operation, the resource, who owns it, the tenant, and the user's role: someone may edit their own records but not another user's. In a multi-tenant application a role like admin is administrator of one organization only, so a check must look at both the permission and the resource's context.

Server-side enforcement follows from defense in depth: authorize on the server, near the operation or in the data layer. The diagram below shows where each check can sit.

  1. 1. Authenticated request

    Page, Server Action or Route Handler invocation

    Untrusted input: ids, form fields, headers.

  2. 2. Session verification

    Session cookie → verified user id

    Answers who. Fails with 401 or a redirect to sign-in.

  3. 3. Role / permission check

    Does a role held here grant this operation?

    Answers may they. Fails with 403.

  4. 4. Resource / tenant check

    Is this row in their tenant, and is it theirs to touch?

    Fails with 404, hiding that the resource exists.

  5. 5. Authorized data access

    Tenant-scoped, column-limited queries

    Returns a DTO, never the raw row.

  6. 6. Neon PostgreSQL

    Constraints, and optionally row-level security

    The last line of defense, not the first.

fig. 2 — layered authorization checks. A proxy.ts guard may sit in front of step 1, but only steps 2–5 are enforcement.

With Drizzle and Neon, the data-access layer is a server-only module that scopes the query to the tenant and applies the policy to the row it loaded:

// server/documents.ts
import "server-only";
import { and, eq } from "drizzle-orm";

import { db } from "@/lib/db";
import { documents } from "@/lib/db/schema";
import { can } from "@/server/policy";
import { requireActor } from "@/server/session";

export async function getDocument(id: string) {
  const actor = await requireActor(); // authentication: who is this?

  const [row] = await db
    .select({ id: documents.id, title: documents.title, ownerId: documents.ownerId })
    .from(documents)
    .where(and(eq(documents.id, id), eq(documents.tenantId, actor.tenantId)))
    .limit(1);

  /* authorization: may they? Missing and "someone else's" look identical. */
  if (!row || !can(actor, "read", row)) return null;

  return row; // a DTO, not the table row
}

Every entry point then calls the same function and stays thin:

// Server Component
const doc = await getDocument(id);
if (!doc) notFound();

// Server Action
"use server";
export async function renameDocument(id: string, formData: FormData) {
  await updateDocumentTitle(id, titleSchema.parse(formData.get("title")));
}

// Route Handler
export async function DELETE(_req: Request, ctx: RouteContext<"/api/documents/[id]">) {
  const { id } = await ctx.params;
  const result = await deleteDocument(id);

  return result.ok
    ? new Response(null, { status: 204 })
    : Response.json({ error: result.reason }, { status: result.status });
}

In a multi-tenant system the role lookup is itself tenant-scoped, so the permission check and the tenant check are usually one lookup. The live demo replays requests against a signed-in-only guard and against this design. Next.js's data security guide recommends the same dedicated Data Access Layer.

t: "stack"

Implementation: the Neon and Drizzle stack

Who does what, and the data model underneath

This pattern uses Neon and Drizzle, the same stack as the rest of this site.

Responsibilities in the reference stack
PieceResponsibility
Auth.jsAuthentication workflows and sessions
Google / GitHubExternal identity providers
NeonThe PostgreSQL database
DrizzleType-safe database access
Application authorization layerRoles, permissions, resource ownership and tenant checks

Auth.js's adapter owns the identity tables (users, accounts, and sessions when you use database sessions); take their shape from the adapter rather than hand-designing it. The tables below are the conceptual part you add: a nullable password hash for Credentials, a coarse account role (the thing the token carries), and per-tenant memberships.

import { pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  email: text("email").notNull().unique(),
  name: text("name"),
  emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
  passwordHash: text("password_hash"), // null for OAuth-only users
  role: text("role", { enum: ["user", "admin"] }).notNull().default("user"),
});

/* One row per provider identity, keyed by provider + its account id. */
export const accounts = pgTable(
  "accounts",
  {
    userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
    provider: text("provider").notNull(),
    providerAccountId: text("provider_account_id").notNull(),
  },
  (t) => [primaryKey({ columns: [t.provider, t.providerAccountId] })],
);

export const roles = pgTable("roles", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull().unique(),
});

export const permissions = pgTable("permissions", {
  id: text("id").primaryKey(), // "reports:read"
});

export const rolePermissions = pgTable(
  "role_permissions",
  {
    roleId: uuid("role_id").notNull().references(() => roles.id),
    permissionId: text("permission_id").notNull().references(() => permissions.id),
  },
  (t) => [primaryKey({ columns: [t.roleId, t.permissionId] })],
);

/* A role is granted per tenant, never globally. */
export const memberships = pgTable(
  "memberships",
  {
    userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
    tenantId: uuid("tenant_id").notNull(),
    roleId: uuid("role_id").notNull().references(() => roles.id),
  },
  (t) => [primaryKey({ columns: [t.userId, t.tenantId] })],
);

Account linking. A matching email is something a provider reports, not proof the person owns the existing account. Linking by email alone lets an attacker who registers the victim's address somewhere that doesn't verify it take over the account. Auth.js refuses implicit linking by default (OAuthAccountNotLinked); the opt-in is literally named allowDangerousEmailAccountLinking. Link automatically only for a provider whose email verification you trust, or require the user to be signed in and add the second provider from inside their account.

t: "modern-notes"

Modern implementation notes

Library choice, session strategy and bundle size, briefly

This comparison lets the Auth.js flow above be weighed against current alternatives.

Auth.js, Better Auth, or in-house

Auth.js, Better Auth and in-house authentication, summarized
DimensionAuth.jsBetter AuthIn-house
Status (2026-10-17)v5 on the beta tag; stewarded by the Better Auth team, security fixes continueActively released (1.7.10 on latest)You track every advisory
SessionsEncrypted-cookie JWT or database sessionsDatabase by default; cookie cache and a stateless modeWhatever you build
RBACVia callbacks; none built inAdmin and organization pluginsEntirely yours

A library takes on the security-sensitive flows — token validation, state, cookies — which is the point. Auth.js is a clear way to learn federated authentication and stateless sessions, and the Better Auth team recommends Better Auth for new projects, so a greenfield app should evaluate both. Neither is universally better; see Better Auth's announcement, Next.js guide and admin plugin.

JWT or database sessions

JWT sessions compared with database sessions
PropertyJWTDatabase
PersistenceLives in the cookie; no store requiredA session row; the cookie holds an id
RevocationNot before expiry without a denylistDelete the row
Database lookupsNone per requestOne per session read
Permission freshnessA snapshot until reissueAs fresh as the row you read

JWTs aren't inherently more secure or more scalable; they trade revocation and freshness for fewer reads. See Auth.js's session strategies.

Bundle size and performance

Installed package size is not browser JavaScript. Server-only authentication code, such as auth() in a Server Component, does not become client JavaScript by itself; it does when a Client Component imports it, for example through a client-side session provider mounted high in the tree. This page publishes no package-size figures. Measure with npx next experimental-analyze (Next.js 16.1+; next build no longer prints per-route sizes) and check the Network tab on a public and a protected route.

Key takeaways

  • Authentication yields an identity; authorization decides what it may do. Design and test them separately.
  • Federated sign-in delegates the identity check, and your application still issues its own session and enforces its own rules.
  • Auth.js v5's flow is one auth.ts, a two-line route handler, JWT callbacks, a custom sign-in page and a proxy — and none of that replaces authorization near the data.
  • Never compare passwords in plaintext; hash with Argon2id and treat Credentials as the provider you maintain yourself.
  • A role in a long-lived token goes stale; decide sensitive things against fresh data, scoped by tenant.
  • Route protection is navigation. Enforce authorization in Server Actions, Route Handlers and the data layer.
t: "session-lifecycle"

Concept: the session lifecycle

Creation, verification on every request, expiry and revocation

Sign-in ends with a session, and a session is only as trustworthy as the check the server makes on every request after it. The lifecycle has four moments: authenticate, create the session, verify it on each request, and end it by expiry or revocation. The choice that shapes the last one is where the truth lives.

Signed JWT compared with a database session
PropertySigned JWTDatabase session
Cookie holdsThe claims, signedA random id
Each requestVerify the signature and expiry; no lookupLook the id up on the server
RevocationNot possible until expiry, unless you add a denylist (and with it, a lookup)One update; the next request fails
Stale dataA role in the token is as old as the tokenRead fresh each time

Signing is not encrypting. A JWT's header and payload are base64url-encoded text that anyone holding the token can read; the signature only proves nobody changed it. Keep secrets and anything personal out of the claims, and keep lifetimes short enough that waiting for expiry is an acceptable revocation story.

The cookie that carries either one should be HttpOnly, Secure and given an explicit SameSite value. SameSite narrows cross-site requests but is one layer against CSRF, not the whole defence. Expiry is enforced on the server as well as in the browser, and a new session id is issued at sign-in and when privileges change.

Revoke a database session and the next request fails. Revoke a JWT and, in the playground below, it keeps working until it expires. Try both.

t: "audit"

Concept: security auditing

Recording who did what, without recording secrets

Authorization decisions are only reviewable if they leave a trace. Log the events that matter later: successful and failed sign-ins, permission denials, changes to protected resources, role changes and session revocations. Each record needs a stable event type, the actor, the resource, the outcome and the reason, plus a time and a request or correlation id.

Logs are read by more people and kept longer than the data they describe, so the discipline is what you leave out: passwords, tokens, raw session ids and full request bodies never belong in them. The demo's buildAuditEvent() enforces that in one place: sensitive-looking keys are replaced by name, anything that is not a primitive is dropped, and the event lists which keys were withheld so a reviewer knows they existed. The same care applies to error messages: say that sign-in failed, not whether the account exists.

t: "implementation"

Implementation example

The document access matrix demo's structure

The demo does not authenticate anyone: a “Sign in as” button just sets a cookie naming a persona, standing in for the whole sign-in step. It starts where sign-in ends: users with per-tenant roles, a pure policy, and a data-access function, so you can see authorization behave correctly and see what a route guard alone would miss.

app/patterns/authentication-authorization/
├── data.ts            ← tenants, roles, users, documents (fixtures)
├── policy.ts          ← decide(): tenant → permission → ownership
├── service.ts         ← data-layer read, route-guard-only read, replay
├── session.ts         ← demo cookie → actor (unknown ids are anonymous)
├── session-lifecycle.ts ← create / verify / revoke, JWT vs database
├── audit.ts           ← buildAuditEvent(): typed events, redacted metadata
├── playground.ts      ← reducer tying session, policy and audit together
├── server-check.ts    ← validated, server-side re-check (ignores claimed roles)
├── actions.ts         ← fake sign-in / sign-out, checkAccessOnServer
└── access-matrix/
    ├── layout.tsx     ← static shell: page chrome
    └── page.tsx       ← controls, permissions, guard comparison

components/patterns/authentication-authorization/
├── playground-section.tsx            (reads the cookie, behind Suspense)
├── session-panel.tsx                 (fake sign-in buttons)
├── access-matrix.tsx                 (one person's access per doc)
├── guard-comparison.tsx              (same requests, two designs)
├── security-console.tsx              (client: owns the playground reducer)
├── operations-panel.tsx              (composes the three sections below)
├── controls-section.tsx              (every button: sign in, request, roles)
├── permissions-section.tsx           (last result, then the live access grid)
├── examples-section.tsx              (fixed scenarios, server check)
├── session-lifecycle-panel.tsx       (phases, JWT vs database, cookie flags)
├── audit-timeline.tsx                (the event log)
├── auth-sequence-diagram.tsx         (fig. 1)
├── layered-authorization-diagram.tsx (fig. 2)
└── comparison-table.tsx              (shared semantic table)

There are three important pieces here.

The policy is one pure function. decide() takes a user, an action and a row, and returns a decision with a reason. It has no I/O, so every rule is a unit test.

The cookie is never trusted for authority. It names a candidate, which is resolved against known users on every read; an unknown or tampered value is anonymous. A real session is signed or stored server-side.

Role is per tenant. Ben is an editor in one tenant and a viewer in another, and the matrix shows the same person getting different answers.

Refusals don't leak existence. A request for a document in another tenant returns the same “not found” as a missing one.

Simulated, and real, are labelled. The playground below the comparison runs as whoever is signed in with the demo cookie, so the page has one identity. It runs in your browser: its sessions, tokens and audit log are illustrative and nothing is stored. One button calls a Server Action that validates its input and re-runs the same policy on the server, ignoring any role the caller claims. That check is real, but the persona id still comes from your browser, so it shows claims being distrusted, not a caller being authenticated.

Look for the amber warnings in the comparison: each is a request a signed-in-only guard would have served.