nextjs-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited nextjs-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Next.js moves fast and its security model has shifted with every major version. The most common failure mode is reaching for a familiar pattern from a year ago that no longer holds — middleware-only auth, naive Server Actions, NEXT_PUBLIC_ env handling. This skill is the spot-check list.
Tested patterns target App Router on Next.js 14+; most apply to 15/16 too. Pages Router callouts are marked.
Middleware runs on every matched request, but it is a network-edge thing — not a substitute for per-route authorization. Treat middleware as a performance optimization for redirects and headers, not as a security boundary.
The 2025 middleware bypass class (CVE-2025-29927-style) showed that header smuggling can skip middleware entirely on misconfigured setups. Patch your Next.js to the fixed version and assume the bypass is possible — every protected route still re-checks auth server-side.
// app/admin/page.tsx — every protected route does its own check
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
export default async function AdminPage() {
const session = await getSession();
if (!session || session.role !== 'admin') redirect('/login');
// ... render
}For Server Actions specifically, re-validate inside the action:
'use server';
export async function deletePost(id: string) {
const session = await getSession();
if (!session) throw new Error('unauthenticated');
if (!canDelete(session.userId, id)) throw new Error('forbidden');
// ... mutate
}A Server Action is callable from any client that knows the action ID; middleware only sees the request, not the action target.
NEXT_PUBLIC_ env leakageAny environment variable starting with NEXT_PUBLIC_ is baked into the client bundle. It is visible to anyone who opens DevTools.
# Find what's actually shipped
grep -r 'NEXT_PUBLIC_' .next/static/chunks 2>/dev/null | headCommon mistakes:
anon key without RLS, Sentry DSN with project-wide scope)NEXT_PUBLIC_ADMIN_API=...Rule: if it would matter that a competitor or attacker sees it, do not prefix it NEXT_PUBLIC_. Use a Server Component / Route Handler / Server Action to mediate access.
Server Actions look like function calls but they are HTTP endpoints. Apply the same hygiene you would to any POST endpoint:
'use server';
import { z } from 'zod';
import { rateLimit } from '@/lib/rate-limit';
const schema = z.object({
title: z.string().min(1).max(200),
bodyHtml: z.string().max(50_000),
});
export async function createPost(input: unknown) {
const session = await requireSession();
await rateLimit(`create-post:${session.userId}`, { window: '1m', max: 5 });
const { title, bodyHtml } = schema.parse(input);
const safeHtml = sanitizeHtml(bodyHtml);
return db.posts.create({ title, bodyHtml: safeHtml, userId: session.userId });
}Specifically:
postId; resolve it via session-scoped queryReact Server Components serialize fetched data and ship it to the client. It is easy to over-fetch and accidentally serialize fields the UI never displays.
// Bad — passes the whole user record (incl. password_hash, email_verified_at) to the client component
const user = await db.users.findUnique({ where: { id }});
return <UserCard user={user} />;
// Good — explicit projection
const user = await db.users.findUnique({
where: { id },
select: { id: true, displayName: true, avatarUrl: true },
});
return <UserCard user={user} />;Audit checklist:
select/pick (or a typed DTO function)JSON.stringify of the page's RSC payload (visible in the HTML source) does not contain anything you would not put in a public API responseInline scripts and styles are common in Next; CSP needs a strategy.
next/script via the nonce prop.Start in Content-Security-Policy-Report-Only for a week, then promote to enforcing.
redirect()Any flow that takes a destination from user input is a potential open redirect:
// Bad
const next = searchParams.get('next');
redirect(next ?? '/');
// Good — allowlist or strip to relative path
function safeNext(next: string | null): string {
if (!next || !next.startsWith('/')) return '/';
if (next.startsWith('//')) return '/'; // protocol-relative would escape
return next;
}Same logic for OAuth callback state, post-login destinations, and "return to" parameters.
next/image and remote patternsnext/image can fetch remote images on behalf of your server. If remotePatterns is too permissive, you have an SSRF + image-resize-amplification gift.
// next.config.js — bad
images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] }
// Good
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
{ protocol: 'https', hostname: 'images.unsplash.com', pathname: '/**' },
],
}Never use domains: ['*'] or remotePatterns matching all hosts in production.
API route handlers under app/api/*/route.ts are public unless protected:
// app/api/admin/users/route.ts
import { requireSession } from '@/lib/auth';
export async function GET() {
const session = await requireSession();
if (session.role !== 'admin') return new Response('forbidden', { status: 403 });
return Response.json(await db.users.findMany({ select: safeFields }));
}
// Be explicit about which methods exist. Unexported methods 405 by default — that's the desired behavior.Mistakes to watch for:
Origin back as Access-Control-Allow-Origin (effectively allow-any)export const runtime = 'edge' changes the threat model:
crypto.subtle and a buggy polyfill, you can get inconsistent behavior between local dev and prod.Pick a runtime per route deliberately, document why, and stick to it.
For routes not behind Cloudflare with header injection, set in next.config.js:
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'X-Frame-Options', value: 'DENY' },
],
}];
}If you are behind Cloudflare, prefer setting these at the edge — see cloudflare-hardening.
package-lock.json, pnpm-lock.yaml) — required for reproducible buildspackage.json, do not rely on caret to pull a fix automaticallyNEXT_PUBLIC_* env contains anything sensitiveredirect() destinations are validated against an allowlist or stripped to relativenext/image remotePatterns is a real allowlist, not **~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.