Cheat SheetsNext.jsAuth & Security

Auth & Security — Cheat Sheet

Next.js · 5 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Auth & Security
Next.js5 topicsQuick revision reference
1

Authentication Patterns

Reading the session on the server changes the shape of auth: there is no loading state, no flash of the signed-out UI, and no token in JavaScript at all.

  • Reading cookies in a server component removes the checking state and the flash of signed-out UI entirely
  • Wrap the session read in cache() so calling it from several components costs one verification per request
  • Cookies can only be set in a server action or route handler, never during a server component render
  • Use the same error message for a wrong password and an unknown email, or you leak which accounts exist
  • When a separate backend owns auth, forward its cookie rather than running a second session system
cookies() + cache(), and requireUser
// lib/auth.ts
import { cookies } from 'next/headers'
import { cache } from 'react'

export const getSession = cache(async () => {
  const token = (await cookies()).get('session')?.value
  if (!token) return null
  try {
    return await verifySession(token)          // JWT verify, or a DB lookup
  } catch {
    return null
  }
})

export async function requireUser() {
  const session = await getSession()
  if (!session) redirect('/login')             // throws
  return session.user
}

// A page — no useEffect, no isLoading, no flash
export default async function DashboardPage() {
  const user = await requireUser()
  const stats = await getStats(user.id)
  return <Dashboard user={user} stats={stats} />
}

// cache() means calling getSession in the layout, the page and three
// components costs one verification per request.

// The client still needs the user for interactive bits — pass it down
// as a prop rather than fetching it again:
<UserMenu user={{ id: user.id, name: user.name }} />
// and select the fields, because those props are visible in the HTML.
2

Authorisation and the Data Access Layer

Checks scattered across pages, actions and handlers get forgotten. Routing every read through one layer that requires the session makes forgetting impossible.

  • Every page, action and route handler is independently reachable and needs its own authorisation check
  • Ownership is the check most often forgotten — put the user id in the query and return 404 rather than 403
  • A Data Access Layer that reads the session itself makes a missing check structurally impossible
  • Mark the DAL server-only and return DTOs, so sensitive fields cannot reach a client component's props
  • Gating in the UI still ships the data — strip Pro-only fields on the server and compute entitlements there
Page, action, handler — and ownership every time
// A page
export default async function AdminPage() {
  const user = await requireUser()
  if (user.role !== 'admin') return <Forbidden />
}

// A server action — a public POST endpoint, remember
'use server'
export async function deleteProblem(id: string) {
  const user = await requireUser()
  if (user.role !== 'admin') throw new Error('Forbidden')
}

// A route handler
export async function DELETE(req: NextRequest) {
  const user = await getSession()
  if (!user) return new NextResponse('Unauthorized', { status: 401 })
}

// Middleware may redirect for convenience, but it is NOT the check —
// see the middleware concept and CVE-2025-29927.

// And the check that is actually forgotten most often is ownership,
// not role:
const submission = await db.submission.findUnique({ where: { id } })
return submission                                   // whose?
const submission = await db.submission.findUnique({
  where: { id, userId: user.id },                   // theirs
})
if (!submission) notFound()                          // 404, not 403 —
                                                     // do not confirm it exists
3

Secrets and Environment Variables

NEXT_PUBLIC_ is a publication decision, not a naming convention. Everything else is about making a wrong import fail the build instead of leaking.

  • NEXT_PUBLIC_ variables are inlined at build time and remain in every shipped bundle — the prefix is a publication decision
  • A client component importing a module that also exports secrets bundles those secrets too
  • server-only turns that mistake into a build failure; client-only does the mirror image
  • Props passed to client components are embedded in the HTML, so select fields rather than spreading objects
  • Validate the environment at startup, keep per-environment credentials, and rotate a leaked secret rather than scrubbing history
Prefixed is published; validate at boot
# .env.local — never committed
DATABASE_URL=postgres://…              # server only
JWT_SECRET=…                            # server only
RAZORPAY_KEY_SECRET=…                   # server only
OPENAI_API_KEY=…                        # server only

NEXT_PUBLIC_API_URL=https://api.aicancode.org      # public, by design
NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_live_…             # publishable key

// Server component / action / route handler — everything is readable
const url = process.env.DATABASE_URL

// Client component — only the prefixed ones exist
process.env.NEXT_PUBLIC_API_URL      // fine
process.env.JWT_SECRET               // undefined, by design

// Two consequences people miss:
//   1. NEXT_PUBLIC_ values are INLINED at build time, so changing one
//      needs a rebuild, not a restart — and the old value stays in
//      every previously deployed bundle.
//   2. A secret placed there is not "leaked if someone looks". It is
//      published. Rotate it; you cannot unpublish it.

// Validate at startup so a missing variable fails the build rather
// than the first request:
export const env = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
}).parse(process.env)
4

Headers, CSP and Common Vulnerabilities

A handful of response headers block whole categories of attack. A Content-Security-Policy is the strongest and the fiddliest, because Next injects inline scripts of its own.

  • HSTS, nosniff, frame-ancestors, Referrer-Policy and Permissions-Policy are one config block that closes several attack classes
  • A strict CSP needs a per-request nonce from middleware, which forfeits static caching — a real trade-off for content sites
  • Omitting your own API from connect-src blocks your fetches and looks exactly like a CORS error
  • CVE-2025-29927 let middleware be bypassed with a header — authorise where data is accessed, never only in middleware
  • Validate redirect destinations against an allow-list and never fetch a user-supplied URL from the server
Five headers, one config block
// next.config.js
async headers() {
  return [{
    source: '/:path*',
    headers: [
      { key: 'Strict-Transport-Security',
        value: 'max-age=63072000; includeSubDomains; preload' },
      { key: 'X-Content-Type-Options', value: 'nosniff' },
      { key: 'X-Frame-Options', value: 'DENY' },          // clickjacking
      { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      { key: 'Permissions-Policy',
        value: 'camera=(), microphone=(), geolocation=()' },
    ],
  }]
}

// What each one prevents:
//   HSTS         a downgrade to http, and cookie theft over it
//   nosniff      a user upload being executed as a script
//   frame DENY   your page embedded in an attacker's iframe
//   Referrer     your full URLs leaking to third parties
//   Permissions  a third-party script silently using the camera

// Verify what actually shipped rather than what you configured — a
// proxy or CDN can strip or override:
curl -sI https://aicancode.org | grep -i strict-transport
// or securityheaders.com for a graded report.
5

Rate Limiting and Abuse Control

Every action and handler is a public endpoint. Without a limit, a login form is a password-guessing API and an AI endpoint is someone else's free compute.

  • Server actions and route handlers are public endpoints — login, OTP, email, reset and AI calls all need limits
  • An unlimited AI endpoint is a free LLM proxy and the fastest way to spend a month's budget
  • Rate limiting must use a shared store; an in-memory map is per-instance and resets on cold start
  • Key on the user id when signed in — an IP-only limit punishes everyone behind one shared connection
  • Add a monthly cost ceiling as well as a rate limit, and keep the error message uninformative
Auth, messaging, AI, reset, search
// 1. Login / OTP — credential stuffing and password spraying.
//    Limit per account AND per IP: an attacker spreading attempts
//    across thousands of accounts stays under a per-account limit.

// 2. Signup — fake accounts, often to farm a free tier.

// 3. Email and SMS — each send costs money, and an OTP endpoint with
//    no limit is a way to bill you for someone else's SMS.

// 4. AI endpoints — the expensive one. An unlimited Aria endpoint is
//    a free LLM proxy, and people do find them.

// 5. Password reset — enumeration plus mail cost.

// 6. Search and anything that hits the database hard.

// The economics: without a limit, one script can spend your monthly
// AI budget in an afternoon. With a limit, it cannot — which is why
// this belongs with security rather than with performance.

// Metered plan limits are a separate thing and also server-side. A
// client-side counter is a suggestion; the server's count is the
// limit. (Pro, Elite and Campus each have their own ceiling here.)
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/nextjs