Auth & Security — Cheat Sheet
Next.js · 5 topics. Download the PDF or the Instagram carousel and share it.
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
// 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.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
// 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 existsSecrets 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
# .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)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
// 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.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
// 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.)