Apply this skill when writing or reviewing any code that touches user input, authentication, database access, API routes, server actions, middleware, environment variables, or external data in a Next.js + TypeScript + Drizzle application. Triggers on requests like "add authentication", "handle user input", "create an API route", "store this in the database", "handle file uploads", "add permissions", "is this safe", or any feature that involves data flowing in from outside the application. Use proactively — security decisions must not be deferred.
80
Quality
80%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Never trust input from outside the application boundary. Every value from users, URLs, query params, external APIs, and even your own DB (after schema changes) must be validated before use.
Every Next.js app needs a middleware.ts at the project root. This is where auth checks, CSP, rate limiting, and origin validation happen before any route code runs.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// CSP with nonce
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
response.headers.set('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self'`,
].join('; '))
// Security headers
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Validate at every entry point. TypeScript types are compile-time only — they don't protect at runtime.
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10_000),
authorId: z.string().uuid(),
})
// In Server Action or API route
export async function createPost(rawInput: unknown) {
const result = CreatePostSchema.safeParse(rawInput)
if (!result.success) return { error: result.error.issues[0].message }
await db.insert(posts).values(result.data)
}Validate on both client (UX) and server (security). Client validation is convenience; server validation is the actual security boundary.
Validate GET query params too — don't parse them manually with parseInt or string checks. Use Zod:
const ListParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().max(200).optional(),
})React's JSX auto-escapes string values in {} — this is your primary defence. The dangerous exceptions:
dangerouslySetInnerHTML With Untrusted Content// ❌ XSS vector
<div dangerouslySetInnerHTML={{ __html: userContent }} />
// ✅ Sanitise first
import DOMPurify from 'dompurify'
const safe = DOMPurify.sanitize(userContent, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p'] })
<div dangerouslySetInnerHTML={{ __html: safe }} />function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol)
} catch {
return false
}
}Never use string concatenation or template literals to build HTML. Always use JSX.
Drizzle parameterises all queries by default. The dangerous exception: raw SQL.
// ✅ Safe — parameterised automatically
const user = await db.select().from(users).where(eq(users.email, email))
// ❌ Dangerous — string interpolation into sql``
const result = await db.execute(sql`SELECT * FROM users WHERE email = '${email}'`)
// ✅ Safe raw SQL — use placeholders
const result = await db.execute(sql`SELECT * FROM users WHERE email = ${email}`)export async function getMyInvoices() {
const session = await getServerSession()
if (!session?.user?.id) throw new Error('Unauthorised')
return db.select().from(invoices).where(eq(invoices.userId, session.user.id))
}Always get the userId from the verified session. Always include an ownership check in queries:
const [invoice] = await db
.select().from(invoices)
.where(and(eq(invoices.id, invoiceId), eq(invoices.userId, session.user.id)))Every Server Action must verify the session — not just the page that calls it.
Server Actions get POST-based CSRF protection automatically from Next.js. REST API routes do not — validate the Origin header:
export async function POST(request: Request) {
const origin = request.headers.get('origin')
const allowedOrigins = [process.env.NEXT_PUBLIC_APP_URL]
if (!origin || !allowedOrigins.includes(origin)) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
// ...
}Auth endpoints are the highest-priority target. Use middleware or a library like @upstash/ratelimit:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests per minute
})
// In middleware or API route
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) return Response.json({ error: 'Too many requests' }, { status: 429 })// lib/env.ts — validate all env vars at startup
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
})
export const env = envSchema.parse(process.env)Rules:
NEXT_PUBLIC_ prefix on secrets).env files — use .env.example with placeholder values// ❌ Never log sensitive values
console.log('Login attempt', { email, password })
// ✅ Redact sensitive fields
console.log('Login attempt', { email, passwordProvided: !!password })
// ❌ Never return password hashes from DB queries
const users = await db.select().from(users)
// ✅ Explicitly exclude sensitive columns
const users = await db.select({ id: users.id, email: users.email, name: users.name }).from(users)OWASP 2025 A10: Mishandling of Exceptional Conditions. Apps must handle errors without leaking stack traces or internal state.
error.message directly to users in production — it may contain internal detailsglobal-error.tsx as the root fallbackRun regularly:
pnpm audit # check for known vulnerabilities
pnpm audit --fix # auto-fix where possibleKeep dependencies updated. Configure Dependabot or Renovate for automated PRs.