DEV Community

Cover image for The Next.js Security Checklist for AI-Generated Code
James Anderson
James Anderson

Posted on

The Next.js Security Checklist for AI-Generated Code

Here's a number that should make you check your own repo: an audit of over 200 vibe-coded applications in early 2026 found that 91.5% contained at least one security vulnerability traceable to AI-generated code. And AI-assisted commits leak secrets at more than double the rate of human-written code.

One security researcher summed up the problem better than any statistic. In a 67-line demo app, the AI he prompted produced: hardcoded JWT secrets, MD5 password hashing, tokens that never expire, an XSS hole, and zero rate limiting — "all in a working application that looks completely normal to a non-security person."

That last phrase is the whole problem. The code works. The demo runs. Nothing errors. And it's full of holes, because the dangerous security mistakes don't announce themselves — they look exactly like success until someone finds them.

Why AI generates insecure Next.js code specifically

Before the checklist, understand why this keeps happening, because it's not random.

An AI coding assistant learned from years of public code — tutorials, Stack Overflow, starter repos. And in that training data, the insecure pattern is often the most common pattern. The most-upvoted JWT tutorial stores the token in localStorage. The quickest auth example checks the session only in middleware. The fastest way to pass data to a component is to hand it the whole database row.

So when you ask AI to "add authentication," it doesn't give you the secure approach — it gives you the popular one, because popular is what it saw most. And Next.js makes this especially dangerous, because server and client code live in the same files, so a single wrong line can quietly ship a secret to the browser or leave a route completely unguarded.

Here's the checklist. Keep it open during your next AI-code review.


☐ 1. Don't trust middleware for auth — it's a UX layer, not a security boundary

This is the biggest one, and it's backed by a string of 2026 CVEs. Attackers found repeated ways to bypass Next.js middleware entirely — with a crafted x-middleware-subrequest header (CVE-2025-29927), with dynamic route parameter injection (CVE-2026-44574), with Turbopack's separate request pipeline (CVE-2026-45109). Next.js 16 even renamed middleware.ts to proxy.ts specifically to signal: this is a routing/UX layer, not a security gate.

AI generates middleware-only auth constantly, because that's the tutorial pattern. It looks airtight and is trivially bypassable.

// ❌ BAD — AI's favorite pattern: middleware is the ONLY gate
// proxy.ts (or middleware.ts)
export function proxy(request: NextRequest) {
  const token = request.cookies.get('session')?.value
  if (!token) return NextResponse.redirect(new URL('/login', request.url))
  return NextResponse.next()
}
// The Route Handler below assumes it's protected. It isn't.
// An attacker who bypasses the proxy layer hits it directly, unauthenticated.
Enter fullscreen mode Exit fullscreen mode

The fix: middleware/proxy can do the fast UX redirect, but every Route Handler and Server Action must independently verify identity at the data layer — the one place that can't be bypassed at the network level.

// ✅ GOOD — verify auth where the data actually lives
// app/api/orders/route.ts
import { verifySession } from '@/lib/auth'

export async function GET() {
  const session = await verifySession()      // checks + cryptographically verifies
  if (!session) return new Response('Unauthorized', { status: 401 })

  // now safe to use session.userId
  const orders = await getOrdersForUser(session.userId)
  return Response.json(orders)
}
Enter fullscreen mode Exit fullscreen mode

Verify: for every protected route, ask "if someone hits this directly, bypassing the proxy, are they still stopped?" If the only check is in middleware, the answer is no.


☐ 2. Every "use server" Server Action is a public POST endpoint

AI treats Server Actions like internal functions you call from a component. They aren't. Every Server Action is a publicly callable POST endpoint — anyone can invoke it directly, with any arguments, regardless of what your UI does. This is the exact class of bug that hit a major vibe-coding platform in 2026, where a handful of API calls from a free account reached any other user's data.

// ❌ BAD — no auth, no ownership check. Anyone can call this with any id.
'use server'
export async function deleteProject(projectId: string) {
  await db.project.delete({ where: { id: projectId } })
}
Enter fullscreen mode Exit fullscreen mode
// ✅ GOOD — auth + ownership + validation, inside the action
'use server'
import { verifySession } from '@/lib/auth'
import { z } from 'zod'

export async function deleteProject(projectId: string) {
  const session = await verifySession()
  if (!session) throw new Error('Unauthorized')

  const id = z.string().uuid().parse(projectId)          // validate input

  const project = await db.project.findUnique({ where: { id } })
  if (project?.ownerId !== session.userId) throw new Error('Forbidden') // ownership

  await db.project.delete({ where: { id } })
}
Enter fullscreen mode Exit fullscreen mode

Verify: treat every "use server" function as a public API endpoint. Does it check who is calling, whether they own the thing, and whether the input is valid — every time?


☐ 3. Never store tokens in localStorage — use httpOnly cookies

The single most common Next.js auth mistake, and AI's default because it's the classic tutorial pattern. Anything in localStorage is readable by any JavaScript on the page — including a script injected by an XSS attack, which can then exfiltrate every user's session.

// ❌ BAD — readable by any script, including an XSS payload
localStorage.setItem('token', userToken)
Enter fullscreen mode Exit fullscreen mode
// ✅ GOOD — httpOnly cookie, set server-side, invisible to JavaScript
// in a Server Action or Route Handler
import { cookies } from 'next/headers'

export async function setSession(token: string) {
  const cookieStore = await cookies()
  cookieStore.set('session', token, {
    httpOnly: true,   // JavaScript cannot read it → survives XSS
    secure: true,     // HTTPS only
    sameSite: 'lax',  // CSRF protection
    path: '/',
    maxAge: 60 * 60 * 24, // keep sessions short
  })
}
Enter fullscreen mode Exit fullscreen mode

"But my Client Component needs the user's name!" — it doesn't need the token for that. Expose a small /api/me Route Handler that reads the cookie server-side and returns only the fields the client needs.

Verify: search your codebase for localStorage.setItem near anything auth-related. If a token lives there, move it to an httpOnly cookie.


☐ 4. Don't leak secrets to the client

Two ways AI ships your secrets to the browser, both silent. First, the NEXT_PUBLIC_ prefix — it publishes a variable to the client bundle. AI sometimes slaps it on things to "make them work," including secrets. Second, using a server-only secret inside a component that's actually a Client Component.

// ❌ BAD — NEXT_PUBLIC_ ships this straight to the browser bundle. Forever.
const stripeSecret = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY

// ❌ BAD — a secret referenced in a "use client" component leaks too
Enter fullscreen mode Exit fullscreen mode
// ✅ GOOD — server-only secret, never prefixed, guarded by the server-only package
// lib/payments.ts
import 'server-only'   // build-time error if this is ever imported client-side

const stripeSecret = process.env.STRIPE_SECRET_KEY   // no NEXT_PUBLIC_
Enter fullscreen mode Exit fullscreen mode

Add a build-time guard so a secret can never accidentally go public:

// lib/env.ts — fail the build if a server secret got a NEXT_PUBLIC_ prefix
const serverOnly = ['STRIPE_SECRET_KEY', 'JWT_SECRET', 'DATABASE_URL']
for (const key of serverOnly) {
  if (process.env[`NEXT_PUBLIC_${key}`]) {
    throw new Error(`Security error: ${key} must not be NEXT_PUBLIC_`)
  }
}
Enter fullscreen mode Exit fullscreen mode

And run a secret scanner — gitleaks or trufflehog — against your repo and your built client bundle. Anything in client-side JavaScript is public. (29 million hardcoded secrets were found on GitHub in 2025, and most never get rotated.)

Verify: grep for NEXT_PUBLIC_ and confirm not one of them is a secret. Scan the built bundle for anything that looks like a key.


☐ 5. Watch the "use client" boundary — it's contagious

Here's the architectural root cause behind mistakes 3 and 4. The moment you put "use client" at the top of a component, every component it imports becomes client code too — it all ships to the browser. AI sprinkles "use client" around liberally to make hooks work, and can accidentally drag secret-touching or data-fetching logic across the line into the browser.

// ❌ BAD — "use client" at the top, then secret-touching code below it
'use client'
import { db } from '@/lib/db'   // this now tries to ship to the browser

export function Dashboard() {
  const data = db.query(...)     // server logic stranded on the client
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The fix is a mental model: server by default, client only at the leaves. Keep "use client" as far down the tree as possible — only the tiny interactive bit (a button, a dropdown) needs it. Fetch data and touch secrets on the server; pass down only what the UI needs.

// ✅ GOOD — server component fetches; a small client leaf handles interaction
// Dashboard.tsx (server component, no "use client")
import { verifySession } from '@/lib/auth'
import { LikeButton } from './LikeButton'   // the only client piece

export default async function Dashboard() {
  const session = await verifySession()
  const data = await getDashboardData(session.userId)   // safe, server-side
  return <LikeButton count={data.likes} />               // pass only what's needed
}
Enter fullscreen mode Exit fullscreen mode

Verify: how far down your tree does your first "use client" sit? If it's on a layout or a big parent, you're shipping more to the browser than you think.


☐ 6. Don't over-pass data from server to client

A subtle leak AI causes constantly: a Server Component fetches a full record and passes the whole object as props to a Client Component — shipping every field to the browser, even ones the UI never displays.

// ❌ BAD — SELECT * then hand the whole row to the client
const user = await db.user.findUnique({ where: { id } })  // includes passwordHash, role, internal flags
return <Profile user={user} />   // all of it is now in the browser's payload
Enter fullscreen mode Exit fullscreen mode
// ✅ GOOD — select only what's needed, pass only what's shown
const user = await db.user.findUnique({
  where: { id },
  select: { displayName: true, avatarUrl: true },   // nothing sensitive
})
return <Profile user={user} />
Enter fullscreen mode Exit fullscreen mode

Treat the server→client props boundary as a trust boundary. If a field crosses it, assume it's public.

Verify: for every object passed from a Server Component to a Client Component, check what's actually in it. SELECT * into props is a leak.


☐ 7. Validate input on the server — always

AI loves to validate on the client ("the form checks it!") and treat that as done. Client validation is a UX nicety; it's trivially bypassed by anyone hitting your endpoint directly. Every Server Action and Route Handler must validate its input server-side, or you're open to injection, SSRF (a real 2026 Next.js CVE class), and malformed-data bugs.

// ❌ BAD — trusts the client to have sent well-formed, safe data
'use server'
export async function updateEmail(email: string) {
  await db.user.update({ where: { id: currentUser() }, data: { email } })
}
Enter fullscreen mode Exit fullscreen mode
// ✅ GOOD — validate on the server with a schema, treat all input as hostile
'use server'
import { z } from 'zod'

const schema = z.object({ email: z.string().email() })

export async function updateEmail(raw: unknown) {
  const { email } = schema.parse(raw)   // throws on anything unexpected
  const session = await verifySession()
  if (!session) throw new Error('Unauthorized')
  await db.user.update({ where: { id: session.userId }, data: { email } })
}
Enter fullscreen mode Exit fullscreen mode

Verify: for every server entry point, is the input parsed and validated on the server, or is it trusting the client?


☐ 8. Get the JWT details right (AI gets them wrong)

If you're hand-rolling JWTs — which AI will happily do — here are the specific things it botches:

  • Hardcoded or weak secret. AI writes const SECRET = 'mysecret'. Generate a real one: openssl rand -base64 32, and keep it in env, not source.
  • alg: none or accepting the token's own alg. Always pin the algorithm on verification.
  • Tokens that never expire. Always set exp. Short-lived access tokens, longer refresh tokens.
  • Weak password hashing. AI still reaches for MD5/SHA-1. Use argon2id or bcrypt. MD5/SHA-1 for passwords is disqualifying.
  • jsonwebtoken in middleware. It relies on Node crypto, which isn't available in the Edge runtime — it can crash or silently misbehave. Use jose, which works on the Edge.
// ✅ GOOD — jose, works on the Edge, algorithm pinned, expiry enforced
import { jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.JWT_SECRET)

export async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],   // pin it — never trust the token's own alg
  })
  return payload
}
Enter fullscreen mode Exit fullscreen mode

Also: checking that a cookie exists is not verifying it. A user can set a fake cookie. You must cryptographically verify the token, not just read it.

Verify: is the secret strong and in env? Is the algorithm pinned? Do tokens expire? Are passwords hashed with argon2/bcrypt, not MD5?


☐ 9. Rate-limit your endpoints

AI almost never adds rate limiting. Without it, login endpoints are open to credential stuffing and brute force, and your RSC/Server Function endpoints are open to denial-of-service (two high-severity DoS CVEs shipped for Next.js in 2026, neither requiring auth). Even a simple per-IP limit at the edge dramatically shrinks the attack surface.

Verify: do your login, password-reset, and expensive endpoints have a rate limit? If AI built them, they almost certainly don't.


☐ 10. Patch Next.js — you're probably on a vulnerable version

AI generates code for whatever patterns dominated its training data, and it will not tell you your framework version has known auth-bypass holes. The May 2026 coordinated release alone fixed 13 CVEs, including three auth-bypass vulnerabilities exploitable without any credentials.

  • Upgrade to a patched version (15.5.18 / 16.2.6 or later).
  • If you're on Next.js 16, run the official codemod to migrate middleware.tsproxy.ts: npx @next/codemod@latest middleware-to-proxy. If you haven't, your route protection may be silently inactive.

Verify: what version are you actually on? Check it against the latest advisories before you ship.


The pattern underneath all ten

Look back at the list and notice what every item has in common: the insecure version works. The localStorage auth logs you in. The middleware-only gate passes your tests. The SELECT * returns data. The NEXT_PUBLIC_ secret makes the feature run. Nothing errors. Nothing goes red.

That's the trap of AI-generated security code, and it's the same trap in everything AI builds: "it works" and "it's secure" are different claims, and only one of them shows up in the demo. The AI is confident, the code is fluent, and the hole is invisible until someone finds it.

So the mindset that actually protects you isn't "trust the AI less." It's: you are the verification layer the AI doesn't have. It writes the code; you own the boundary — where auth is checked, what crosses to the client, what a secret is, what gets validated. Those are decisions about your app's trust model, and no model can make them for you, because it doesn't know your threat model. It only knows what code usually looks like.

Run this today

Don't wait for a rewrite. Right now:

  1. Run gitleaks or trufflehog against your repo and your built client bundle.
  2. Grep for localStorage.setItem and NEXT_PUBLIC_ and audit every hit.
  3. Pick your three most sensitive Server Actions and confirm each checks auth, ownership, and input.
  4. Check your Next.js version against the latest advisories.
  5. Keep this checklist open the next time you accept AI-generated code.

The barrier to writing Next.js code has collapsed. The barrier to securing it hasn't moved an inch — and that part is entirely yours.


What's the scariest thing you've caught in AI-generated code — or, worse, shipped and found later? I collect these, and the "it looked completely fine" stories are the ones worth learning from. Drop yours in the comments.

Top comments (4)

Collapse
 
debashish_ghosal profile image
Debashish Ghosal

Really useful checklist — the framing that stuck with me is "the insecure version works." That's exactly why AI-generated holes survive review: the demo runs, nothing errors, and the middleware-only gate passes your happy-path tests. Your point about AI serving the popular pattern rather than the secure one (localStorage tokens, SELECT * into props) explains so many of the PRs I've seen lately.

Key takeaway I'm stealing: verify auth where the data lives, not where the route starts. The "hit this directly, bypassing the proxy — are they still stopped?" test is the one-liner I'll add to our review template.

One suggestion / question: have you considered adding a pre-commit pairing to the "run this today" list — e.g. gitleaks as a hook plus a NEXT_PUBLIC_ allowlist check in CI? I found secret-in-bundle leaks only get caught when the built output is scanned, not just the repo. Curious whether you've seen teams automate the server→client props audit, or is that still manual grep work in practice?

Thanks for collecting the CVE references (CVE-2025-29927 especially) — makes this actionable instead of theoretical.

Collapse
 
james_anderson_h profile image
James Anderson

"Verify auth where the data lives, not where the route starts" — you compressed the whole first item better than I did. And putting the "hit this directly, bypassing the proxy — are they still stopped?" question into your review template is exactly the right move, because it turns a principle into a check someone actually runs. A rule that lives in a blog post gets forgotten; a rule that lives in the review template gets enforced.

Your pre-commit suggestion is better than what I put in "run this today," and you're right about why: scanning the repo catches secrets in source, but the dangerous ones are the secrets that get bundled — a NEXT_PUBLIC_ leak or a secret pulled into a client component doesn't exist as a hardcoded string in your repo, it gets compiled into the client output. So a repo-only scan gives you a green check while the built bundle is shipping the key. The pairing I'd actually recommend, based on that: gitleaks as a pre-commit hook for the source-level stuff (hardcoded keys, .env slips), plus a CI step that runs the production build and scans the client bundle — that's the layer that catches the leak the repo scan structurally can't see. Two different scans for two different failure modes, and teams usually only wire the first. Add the NEXT_PUBLIC_ allowlist check in CI (fail the build if a var not on the allowlist carries the prefix) and you've closed the "made it public to make it work" path at the gate instead of in review.

On the server→client props audit — honest answer: I have not seen it well automated, and it's the weakest link in the tooling story. Most teams are still doing manual grep / eyeballing what gets passed as props, which means it's exactly the check that silently lapses under deadline. The partial automation I've seen: linting for select clauses / discouraging raw SELECT * at the query layer so the sensitive fields never get fetched in the first place (fix it upstream of the props boundary), and DTO/serializer patterns where a client-facing type is defined explicitly so passing a raw DB row is a type error. But "prove no sensitive field crossed the props boundary" as a general automated check is still an open gap as far as I've seen — it's semantic (which fields are sensitive?), so it resists a generic rule. If anyone reading has automated it properly, I'd genuinely want to know, because right now it's the one item on the list that's still mostly discipline rather than a gate. Great additions — the built-bundle-scan distinction is going into the "run this today" section with credit.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.