Last Minute Notes

Auth, Security, and Proxy

Authentication and security in Next.js: proxy.ts (formerly middleware) and what it should and should not do, session cookies vs JWTs, the Data Access Layer pattern, authorization at every boundary, Auth.js and other libraries, OAuth flows, CSRF, security headers and CSP with nonces, server-only code, secrets, rate limiting, and a production checklist.

📘 Auth, Security, and Proxy

Next.js blurs the line between front end and back end, which makes it easy to put a check in the wrong place. The core idea of this page: authenticate and authorize as close to the data as possible, and treat the proxy as a coarse early filter, not the security boundary.

Table of Contents

  1. Authentication vs Authorization
  2. proxy.ts (Formerly Middleware)
  3. Sessions: Cookies vs JWTs
  4. Implementing Sessions
  5. The Data Access Layer
  6. Where to Check Auth
  7. Auth Libraries and OAuth
  8. CSRF, XSS, and Cookies
  9. Security Headers and CSP
  10. Secrets and Server-only Code
  11. Rate Limiting and Abuse
  12. Production Checklist
  13. Questions

1. Authentication vs Authorization

Authentication (authn)Authorization (authz)
QuestionWho are you?What may you do?
ExamplesPassword, OAuth, passkey, magic linkRoles, ownership, permissions, tenant membership
Failure response401 Unauthorized403 Forbidden
WhereLogin flow, session verificationEvery read and write of protected data

Three layers to cover:

  1. Sign in: verify credentials and create a session.
  2. Session management: store, verify, rotate, and destroy the session.
  3. Authorization: check the session against the specific resource, on every request.

2. proxy.ts (Formerly Middleware)

proxy.ts (renamed from middleware.ts in current versions) runs before a request completes, ahead of routing and rendering. It sits at the project root (or src/) and runs on the Node.js runtime. middleware.ts is deprecated; a codemod renames it.

// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  const hasSession = request.cookies.has('session');
  const { pathname } = request.nextUrl;

  if (pathname.startsWith('/dashboard') && !hasSession) {
    const url = new URL('/login', request.url);
    url.searchParams.set('next', pathname);
    return NextResponse.redirect(url);
  }
  return NextResponse.next();
}

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

What it is good for:

Good useWhy
Optimistic redirect of anonymous users to /loginCheap, early, avoids rendering the page
Locale detection and redirectsRuns before routing
A/B test bucketing, feature-flag cookiesSet cookie and rewrite
Rewrites for multi-tenant subdomainsacme.app.com to /tenants/acme
Adding headers (CSP nonce, request id)Applies to every matched request
Bot or geo blockingQuick rejections

What it is not:

  • Not your only auth check. A proxy matcher can miss routes (for example Server Action POSTs to a page URL, or routes added later), and a vulnerability in a single layer must not expose data. A real incident class (CVE-2025-29927) bypassed middleware auth by spoofing an internal header; apps relying only on middleware were exposed.
  • Not for heavy work: it runs on every matched request, including prefetches, so no slow database queries or large dependencies.
  • Not a place for data fetching or business logic.
  • It can read and set cookies and headers and rewrite or redirect, but does not render React.

The matcher matters: exclude static assets and images, include API routes if you want them covered, and remember it runs on prefetch requests too.


3. Sessions: Cookies vs JWTs

Server-side session (opaque id in cookie)Stateless JWT in cookie
Cookie holdsRandom session idSigned (and optionally encrypted) claims
Server stateSession store (DB / Redis)None
RevocationImmediate (delete the row)Hard until expiry (need deny-list)
Per-request costStore lookupSignature check only
SizeTinyGrows with claims
Best forMost apps, sensitive systems, instant logoutSimple apps, edge verification, short-lived access tokens

Guidance:

  • Prefer server-side sessions (or short-lived JWT plus refresh token rotation) for anything sensitive; revocation and "log out everywhere" are easy with a store.
  • Use stateless tokens when you need to verify without a database round trip (edge, third-party APIs).
  • Never put sensitive data in a JWT payload without encryption; it is only base64-encoded.
  • Always use HttpOnly, Secure, SameSite=Lax (or Strict) cookies, a sensible Path, and a bounded maxAge. Do not store tokens in localStorage: any XSS can read it.
Cookie flagEffect
HttpOnlyJavaScript cannot read it, blocks theft via XSS
SecureSent only over HTTPS
SameSite=LaxNot sent on cross-site subrequests (blocks most CSRF), sent on top-level GET navigations
SameSite=StrictNever sent cross-site (can break returning from external links)
__Host- prefixForces Secure, Path=/, no Domain; strongest binding

4. Implementing Sessions

A minimal stateless session with jose (signed JWT in an HttpOnly cookie):

// lib/session.ts
import 'server-only';
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';

const key = new TextEncoder().encode(process.env.SESSION_SECRET);

export async function createSession(userId: string, role: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  const token = await new SignJWT({ userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime(expiresAt)
    .sign(key);

  (await cookies()).set('session', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    expires: expiresAt,
    path: '/',
  });
}

export async function readSession() {
  const token = (await cookies()).get('session')?.value;
  if (!token) return null;
  try {
    const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
    return payload as { userId: string; role: string };
  } catch {
    return null;
  }
}

export async function deleteSession() {
  (await cookies()).delete('session');
}

Login action:

'use server';
export async function login(prev: unknown, formData: FormData) {
  const parsed = LoginSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: 'Invalid input' };

  const user = await db.user.findUnique({ where: { email: parsed.data.email } });
  const ok = user && (await verifyPassword(parsed.data.password, user.passwordHash)); // argon2 / bcrypt
  if (!ok) return { error: 'Invalid email or password' };      // same message either way

  await createSession(user.id, user.role);
  redirect('/dashboard');
}

Hardening notes:

  • Hash passwords with Argon2id or bcrypt/scrypt; never a fast hash. Never log passwords.
  • Return the same error for unknown email and wrong password to avoid account enumeration.
  • Rotate the session on login and privilege change (prevents session fixation).
  • Add rate limiting and lockout/backoff on login.
  • For database sessions, store a random 128-bit+ id, hash it at rest, and keep expiresAt, user agent, and IP metadata for session management UIs.

5. The Data Access Layer

The Data Access Layer (DAL) is a server-only module through which all protected data is read. Each function verifies the session and authorizes before touching the database, and returns DTOs (only the fields the caller may see).

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { readSession } from '@/lib/session';

export const verifySession = cache(async () => {
  const session = await readSession();
  if (!session) redirect('/login');
  return session;                        // deduped within one request
});

export const getCurrentUser = cache(async () => {
  const { userId } = await verifySession();
  const user = await db.user.findUnique({ where: { id: userId } });
  return user && { id: user.id, name: user.name, email: user.email };   // DTO: no passwordHash
});

export async function getInvoice(id: string) {
  const { userId, role } = await verifySession();
  const invoice = await db.invoice.findUnique({ where: { id } });
  if (!invoice) return null;
  if (invoice.ownerId !== userId && role !== 'admin') return null;     // authorization on the resource
  return { id: invoice.id, total: invoice.total, status: invoice.status };
}

Why this pattern:

  • One place to audit security instead of auth checks sprinkled across components.
  • It makes it hard to forget: components call getInvoice, never db.invoice directly.
  • server-only guarantees it can never be bundled into client code.
  • DTOs prevent leaking sensitive columns into the RSC payload.
  • cache() dedupes verifySession so calling it from many components costs one lookup per request.

6. Where to Check Auth

LocationCheck?Why
proxy.tsCoarse: cookie present, redirect earlyFast UX; not a security boundary
LayoutDo not rely on itLayouts do not re-render on navigation between children, so a check there can be skipped; and partial rendering means a page can render independently
Page / Server ComponentYes, via the DALClose to rendering the data
DAL functionsYes, alwaysThe real boundary, every data access
Server ActionsYes, alwaysThey are public POST endpoints
Route HandlersYes, alwaysPublic HTTP endpoints
Client ComponentsUX only (hide buttons)Never security

Role-based UI pattern:

export default async function AdminPage() {
  const user = await getCurrentUser();
  if (user?.role !== 'admin') forbidden();       // renders forbidden.tsx with 403
  return <AdminPanel />;
}

Common authorization models: RBAC (roles), ABAC (attributes), ownership checks, multi-tenant scoping (always include tenantId in queries; use database row-level security as a backstop).

Fail closed: if the session cannot be verified or the resource cannot be matched, deny.


7. Auth Libraries and OAuth

OptionNotes
Auth.js (NextAuth v5)Providers (Google, GitHub), adapters, session strategies, works with App Router; widely used
Better AuthFramework-agnostic TypeScript auth with plugins (2FA, orgs, passkeys)
Clerk / Auth0 / WorkOS / Supabase AuthHosted identity, fastest to ship, vendor lock-in and cost
Hand-rolled with jose + DBFull control; you own the security details
Passkeys / WebAuthnPhishing-resistant; SimpleWebAuthn or provider support

Rule: do not invent crypto or session protocols; use a maintained library or a hosted provider unless you have a reason.

OAuth / OIDC authorization code flow with PKCE:

  • state prevents CSRF on the callback, PKCE protects the code exchange.
  • Validate the id_token signature, iss, aud, exp, and nonce.
  • Link accounts by verified email only, to avoid account takeover.
  • Keep refresh tokens server-side, encrypted at rest.

8. CSRF, XSS, and Cookies

CSRF (cross-site request forgery): another site makes the user's browser send an authenticated request to yours.

DefenseDetail
SameSite=Lax cookiesDefault in browsers; blocks cross-site POSTs carrying the cookie
Server ActionsPOST only, and Next.js compares Origin with Host/X-Forwarded-Host, rejecting mismatches
serverActions.allowedOriginsNeeded when a reverse proxy or multiple domains legitimately differ
CSRF tokensFor Route Handlers that accept cookie-authenticated state changes from forms
Do not mutate on GETSafe methods must be side-effect free

XSS (cross-site scripting): attacker script runs in your page.

  • React escapes interpolated values by default.
  • Danger zones: dangerouslySetInnerHTML, href={userInput} (javascript: URLs), HTML in markdown, third-party scripts, eval.
  • Sanitize untrusted HTML with a vetted library (DOMPurify via isomorphic-dompurify), allowlist URL schemes.
  • HttpOnly cookies limit damage, CSP limits injected script execution.

Other injection: use parameterized queries or an ORM (SQL injection), validate and constrain file paths (path traversal), validate outbound URLs and block internal IP ranges (SSRF when the server fetches user-supplied URLs).


9. Security Headers and CSP

// next.config.ts
const securityHeaders = [
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }];
  },
};
HeaderProtects against
Content-Security-PolicyXSS, data injection, clickjacking (frame-ancestors)
Strict-Transport-SecurityProtocol downgrade, cookie hijacking
X-Content-Type-Options: nosniffMIME sniffing attacks
Referrer-PolicyLeaking URLs to other sites
X-Frame-Options / frame-ancestorsClickjacking
Permissions-PolicyUnwanted browser features

CSP with nonces (strict policy that still allows Next.js inline scripts):

// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' blob: data: https:`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `frame-ancestors 'none'`,
  ].join('; ');

  const headers = new Headers(request.headers);
  headers.set('x-nonce', nonce);
  headers.set('Content-Security-Policy', csp);

  const response = NextResponse.next({ request: { headers } });
  response.headers.set('Content-Security-Policy', csp);
  return response;
}
  • Next.js reads the x-nonce request header and applies the nonce to its scripts during rendering.
  • Pages using nonces must be dynamically rendered, because a static page cannot embed a per-request nonce. This is the trade-off: strict CSP versus fully static pages. Subresource Integrity (SRI) is the alternative for static pages.
  • Roll out with Content-Security-Policy-Report-Only first and collect violation reports.

10. Secrets and Server-only Code

  • Keep secrets in environment variables on the server, never prefixed NEXT_PUBLIC_.
  • Mark data-access and secret-using modules import 'server-only' so accidental client imports fail the build.
  • Use experimental_taintObjectReference and experimental_taintUniqueValue to make React throw if a user object or token is passed to a Client Component.
  • Props to Client Components appear in the page source: pass minimal DTOs.
  • Do not log tokens or full request bodies; scrub PII in logs and error reports.
  • Rotate secrets, use different secrets per environment, and keep them in the platform's secret manager rather than in the repo (.env.local is for local only).
  • NEXT_PUBLIC_ values are inlined at build time and readable by anyone.
  • Dependency hygiene: pin versions, run npm audit/Dependabot, and keep Next.js patched since framework-level CVEs exist (middleware bypass, SSRF in image optimizer, RSC protocol issues).

11. Rate Limiting and Abuse

TargetLimit by
Login, signup, password reset, OTPIP and account identifier; exponential backoff; CAPTCHA after repeated failures
Expensive actions (exports, AI calls)User id with a token bucket
Public Route HandlersAPI key or IP
WebhooksSignature verification, idempotency keys
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const limiter = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(5, '1 m') });

export async function login(prev: unknown, formData: FormData) {
  const ip = (await headers()).get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
  const { success } = await limiter.limit(`login:${ip}`);
  if (!success) return { error: 'Too many attempts, try again later' };
  // ...
}
  • In-memory counters do not work across serverless instances; use a shared store (Redis).
  • Trust x-forwarded-for only from your own proxy; otherwise clients can spoof it.
  • Also rate limit at the edge/CDN/WAF for volumetric abuse.

12. Production Checklist

AreaCheck
SessionsHttpOnly + Secure + SameSite cookies, expiry, rotation, revocation
AuthzEvery DAL function, Action, and Route Handler checks identity and resource ownership
InputServer-side schema validation on every external input
OutputDTOs, no secrets or extra columns in props or JSON
ProxyCoarse filter only, matcher excludes static files, no heavy work
HeadersCSP (report-only first), HSTS, nosniff, frame protections
CSRFSameSite, allowedOrigins configured behind proxies
SecretsNot in NEXT_PUBLIC_, not in the repo, rotated
DependenciesNext.js and packages patched, audit in CI
AbuseRate limiting on auth and costly endpoints
LoggingAudit log of sensitive actions, no secrets in logs
ErrorsGeneric messages to users, digest-based correlation in logs
Upload / SSRFValidate type and size, block internal URLs in server-side fetches

13. Questions

Q: What is proxy.ts and how is it different from middleware.ts? It is the renamed middleware file: code that runs before a request is completed, for redirects, rewrites, and header changes. In current versions it runs on the Node.js runtime and middleware.ts is deprecated.

Q: Is it safe to protect routes only in the proxy? No. Use it for early redirects, but enforce authentication and authorization in the Data Access Layer, Server Actions, and Route Handlers. Matchers can miss paths, and middleware-only auth was bypassed in a real CVE.

Q: Why not check auth in a layout? Layouts are not re-rendered when navigating between their children, and pages can be rendered independently, so the check can be skipped. Check in each page and, more importantly, in the data layer.

Q: Cookie sessions or JWT? Server-side sessions for easy revocation and sensitive apps. JWT when stateless verification is required, ideally short-lived with refresh rotation. Either way, deliver via HttpOnly, Secure, SameSite cookies, not localStorage.

Q: How do Server Actions defend against CSRF? POST-only plus an Origin vs Host check, along with SameSite cookies. Add allowedOrigins when behind proxies, and still authenticate and authorize inside the action.

Q: How do you implement a strict CSP in Next.js? Generate a nonce per request in proxy.ts, set it in the CSP header and an x-nonce request header, and use 'strict-dynamic'. Pages become dynamic; start in report-only mode.

Q: How do you stop server code reaching the client? import 'server-only', a DAL that returns DTOs, never prefixing secrets with NEXT_PUBLIC_, and taint APIs for sensitive objects.

Q: A user can open another user's invoice by changing the id in the URL. Why, and how do you fix it? Missing resource-level authorization (IDOR). Check ownership or permission inside the DAL for every access, scope queries by user or tenant, and add tests plus database row-level security as a backstop.

Q: How would you design login with Google for a Next.js app? OAuth authorization code flow with PKCE and state via Auth.js or a hosted provider, verify the ID token, find or link the user by verified email, create a server session with an HttpOnly cookie, and redirect. Keep refresh tokens server-side and encrypted.

On this page