Why Next.js APIs Are a Common Target
Next.js makes building APIs trivial — drop a file in app/api/ or pages/api/ and you have a public endpoint. That ease of use is the problem: routes ship to production with security implications nobody noticed at code review.
Below are the 10 most common Next.js API vulnerabilities SableOffensive finds in startup pentests, with concrete code examples and fixes. Roughly 80% of the Next.js apps we audit have at least 3 of these issues.
1. Missing Authentication on API Routes
The vulnerability: Any route in /api/* is publicly accessible by default. Without explicit auth checks, anyone can call it.
// VULNERABLE
export async function GET(req: Request) {
const users = await db.user.findMany()
return Response.json(users) // Anyone can list all users
}
The fix: Add auth middleware or explicit checks in every route.
// FIXED
import { auth } from '@/lib/auth'
export async function GET(req: Request) {
const session = await auth()
if (!session?.user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await db.user.findMany()
return Response.json(users)
}
Better: Use Next.js middleware (middleware.ts) to enforce auth across all routes by default, and explicitly mark public ones.
2. BOLA / IDOR (Broken Object Level Authorization)
The vulnerability: The most common API issue. The user is authenticated, but the route doesn't check if they own the requested resource.
// VULNERABLE
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await auth()
if (!session) return new Response('Unauthorized', { status: 401 })
// Anyone authenticated can read ANY order by changing the URL
const order = await db.order.findUnique({ where: { id: params.id } })
return Response.json(order)
}
The fix: Always verify the authenticated user owns the resource.
// FIXED
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await auth()
if (!session) return new Response('Unauthorized', { status: 401 })
const order = await db.order.findUnique({
where: { id: params.id, userId: session.user.id } // Ownership check
})
if (!order) return new Response('Not found', { status: 404 })
return Response.json(order)
}
3. Exposed Environment Variables (NEXT_PUBLIC_ prefix)
The vulnerability: Any env var prefixed with NEXT_PUBLIC_ ships to the browser bundle. Developers accidentally expose backend secrets.
# VULNERABLE .env.local
NEXT_PUBLIC_API_KEY=sk_live_dangerous_key_in_browser
NEXT_PUBLIC_SUPABASE_SERVICE_KEY=admin_bypass_rls_key
The fix: Never use NEXT_PUBLIC_ for anything sensitive. Audit your bundle:
# Find any leaked secret in production bundle
npm run build
grep -r "sk_live\|sk_test\|service_role" .next/static/
Use Server Components or API routes to keep secrets server-side. Anything the browser doesn't strictly need should never have NEXT_PUBLIC_.
4. Server Actions Without CSRF Protection
The vulnerability: Next.js Server Actions are RPC-style endpoints. They include some CSRF protection by default (Origin header check), but custom action handlers or older versions are vulnerable.
// VULNERABLE pattern: action that performs sensitive change without origin verification
async function transferFunds(formData: FormData) {
'use server'
const session = await auth()
await db.transfer({
from: session.user.id,
to: formData.get('to'),
amount: formData.get('amount')
})
}
The fix: Verify the request Origin matches your domain explicitly + add CSRF tokens for state-changing actions.
async function transferFunds(formData: FormData) {
'use server'
const headersList = headers()
const origin = headersList.get('origin')
if (origin !== process.env.NEXT_PUBLIC_APP_URL) {
throw new Error('Invalid origin')
}
// ... continue
}
5. SSRF via Unvalidated URL Inputs
The vulnerability: Routes that fetch URLs based on user input can be exploited to scan internal infrastructure.
// VULNERABLE
export async function POST(req: Request) {
const { url } = await req.json()
const response = await fetch(url) // Attacker can hit internal services
return Response.json(await response.json())
}
Attacker payloads:
http://localhost:3000/api/admin— internal admin APIhttp://169.254.169.254/latest/meta-data/— AWS instance metadatahttp://192.168.1.1— internal network scan
The fix: Validate URLs against an allowlist + block internal IP ranges.
const parsed = new URL(url)
if (
parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname.startsWith('192.168.') ||
parsed.hostname.startsWith('10.') ||
parsed.hostname.startsWith('169.254.') ||
parsed.hostname.endsWith('.internal')
) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
6. No Rate Limiting on Auth Endpoints
The vulnerability: Login, signup, and password reset endpoints without rate limiting allow:
- Credential stuffing attacks (testing leaked passwords)
- Brute-force password guessing
- Email enumeration via response timing differences
- SMS bombing via password reset
The fix: Use Upstash Ratelimit, Vercel KV, or similar.
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15 m') // 5 attempts per 15 min
})
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'
const { success } = await ratelimit.limit(`login_${ip}`)
if (!success) {
return Response.json({ error: 'Too many attempts' }, { status: 429 })
}
// ... login logic
}
7. Mass Assignment via Spread Operators
The vulnerability: Spreading user input directly into a database call lets attackers set fields they shouldn't control (like role, isAdmin, userId).
// VULNERABLE
export async function POST(req: Request) {
const session = await auth()
const body = await req.json()
// Attacker sends { name: "x", role: "admin" }
const user = await db.user.update({
where: { id: session.user.id },
data: { ...body }
})
}
The fix: Validate with Zod/Yup and explicitly pick only allowed fields.
import { z } from 'zod'
const UpdateProfileSchema = z.object({
name: z.string().max(100),
bio: z.string().max(500).optional()
})
export async function POST(req: Request) {
const session = await auth()
const body = await req.json()
const data = UpdateProfileSchema.parse(body) // Throws on extra fields
// ... only safe fields proceed
}
8. SQL Injection via Raw Queries
The vulnerability: Even with Prisma or Drizzle, dropping into raw queries with template literals reintroduces SQL injection.
// VULNERABLE
const orders = await db.$queryRawUnsafe(
`SELECT * FROM orders WHERE user_id = '${userId}'`
)
The fix: Use parameterized queries.
// FIXED
const orders = await db.$queryRaw`
SELECT * FROM orders WHERE user_id = ${userId}
` // Prisma's tagged template safely parameterizes
9. Unsafe File Uploads
The vulnerability: Routes that accept file uploads without validation allow:
- Path traversal (
../../etc/passwd) - Executable upload (PHP, JSP files served from public bucket)
- Storage exhaustion DoS
- Polyglot files (image with embedded JavaScript)
The fix:
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export async function POST(req: Request) {
const formData = await req.formData()
const file = formData.get('file') as File
if (!ALLOWED_TYPES.includes(file.type)) {
return Response.json({ error: 'Invalid type' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return Response.json({ error: 'File too large' }, { status: 400 })
}
// Use a UUID filename, never user-supplied
const filename = `${crypto.randomUUID()}.${file.type.split('/')[1]}`
// ... save to S3/Supabase Storage
}
10. Missing CORS Configuration
The vulnerability: Default Next.js doesn't set CORS headers — APIs are same-origin only. But developers often add Access-Control-Allow-Origin: * to "fix" CORS errors, opening the API to any origin.
// VULNERABLE
export async function GET(req: Request) {
const data = await getSensitiveData()
return new Response(JSON.stringify(data), {
headers: {
'Access-Control-Allow-Origin': '*', // Any site can call this
'Access-Control-Allow-Credentials': 'true' // With user's cookies
}
})
}
The fix: Allow specific origins only.
const ALLOWED_ORIGINS = [
'https://yourdomain.com',
'https://app.yourdomain.com'
]
export async function GET(req: Request) {
const origin = req.headers.get('origin')
const allowOrigin = ALLOWED_ORIGINS.includes(origin || '') ? origin : ''
return new Response(JSON.stringify(data), {
headers: {
'Access-Control-Allow-Origin': allowOrigin || ALLOWED_ORIGINS[0],
'Access-Control-Allow-Credentials': 'true'
}
})
}
How to Find These in Your Codebase
Quick self-audit checklist:
# Find API routes without auth checks
grep -rL "auth()\|getSession\|getServerSession" src/app/api/
# Find NEXT_PUBLIC_ env vars (potential leaks)
grep -r "NEXT_PUBLIC_" .env* src/
# Find raw SQL queries
grep -rn "queryRawUnsafe\|\$queryRaw\`" src/
# Find spread into db calls (mass assignment risk)
grep -rn "\.\.\..*\(body\|formData\|req\)" src/app/api/
Pentesting Your Next.js App
This list catches the obvious patterns. Real vulnerabilities often hide in:
- Middleware bypass via path normalization — middleware checks one path, route is reached via another.
- Server Actions race conditions — concurrent calls bypass uniqueness checks.
- Auth state confusion in App Router — RSC and Client Components have different access to session.
- Edge Runtime constraints — limited Node API surface causes developers to skip security libraries.
To find these, you need a manual pentest. SableOffensive specializes in Next.js security audits:
- Free headers check — validates your Next.js security headers in 3 seconds.
- Pre-Launch Check ($29) — automated coverage of OWASP basics + Next.js-specific patterns.
- Founder Shield ($79) — manual review of all
/apiroutes + Server Actions + middleware. - Scale Secure ($199) — full code review + penetration testing + 30-day continuous monitoring.
Questions or need a custom engagement? Email [email protected].