CtrlK
BlogDocsLog inGet started
Tessl Logo

product-factory/security

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

Overview
Skills
Evals
Files
name:
security
description:
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.

Security: Next.js App Router + TypeScript + Drizzle

The Core Rule

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.


Middleware — First Line of Defence

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).*)'],
}

Input Validation — Always Zod

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(),
})

XSS Prevention

React's JSX auto-escapes string values in {} — this is your primary defence. The dangerous exceptions:

Never Use 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 }} />

Validate URLs Before Rendering as Links

function isSafeUrl(url: string): boolean {
  try {
    const parsed = new URL(url)
    return ['http:', 'https:'].includes(parsed.protocol)
  } catch {
    return false
  }
}

Never Construct HTML Strings

Never use string concatenation or template literals to build HTML. Always use JSX.


SQL Injection — Drizzle Protects You (When Used Correctly)

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}`)

Authentication & Authorisation

Verify Session Server-Side on Every Protected Action

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))
}

Never Trust Client-Supplied User IDs

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)))

Server Actions Need Auth Too

Every Server Action must verify the session — not just the page that calls it.


CSRF Protection

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 })
  }
  // ...
}

Rate Limiting

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 })

Environment Variables

// 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:

  • Never expose server-only env vars to the client (no NEXT_PUBLIC_ prefix on secrets)
  • Never commit .env files — use .env.example with placeholder values
  • Never log env vars

Sensitive Data Handling

// ❌ 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)

Error Handling — Don't Leak Internals

OWASP 2025 A10: Mishandling of Exceptional Conditions. Apps must handle errors without leaking stack traces or internal state.

  • Never display error.message directly to users in production — it may contain internal details
  • Use global-error.tsx as the root fallback
  • Log errors server-side with structured logging, return generic messages to clients
  • Never fail open — if auth/validation throws, deny access by default

Dependency Security

Run regularly:

pnpm audit                   # check for known vulnerabilities
pnpm audit --fix             # auto-fix where possible

Keep dependencies updated. Configure Dependabot or Renovate for automated PRs.

Install with Tessl CLI

npx tessl i product-factory/security@0.2.0
Workspace
product-factory
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
product-factory/security badge