Next.js App-Layer Security: Beyond RLS and Secrets
Next.js app-layer security is everything that isn't Row Level Security or secrets management: XSS through dangerouslySetInnerHTML, eval on untrusted input, wildcard CORS, unvalidated request bodies, and known-vulnerable dependencies. Each is a small, specific fix — and each is invisible until someone exploits it.
Most Next.js + Supabase security advice stops at two things: get your RLS right and keep your secrets server-side. Those are the two biggest levers, and they belong first. But a locked database and hidden keys still leave a third surface exposed: the code that runs between the request and the response. That's the app layer, and it's where the boring, exploitable bugs live.
This is the pillar guide to that layer. Each section is a real footgun with a real fix, and links to a deeper dive where one exists. If you've already handled RLS and secrets, this is your next checklist.
How do I secure the app layer in a Next.js app?
Work through five recurring failure classes, in roughly this order: render untrusted HTML safely, never eval dynamic input, lock down CORS, validate every request body, and patch known-vulnerable dependencies. None are exotic, and all of them ship in real apps every week — the framework doesn't stop you, and nothing breaks when you get them wrong.
That's what makes the app layer different from RLS and secrets: the compiler and the runtime are perfectly happy with every one of these bugs. No error, no failed build, no warning at deploy. You find out when a scanner flags it, or when someone else does.
1. Cross-site scripting through raw HTML
React escapes everything you interpolate into JSX by default, which is why XSS is rare in idiomatic components. The one escape hatch is dangerouslySetInnerHTML, and the name is the documentation. The moment any user-controlled string reaches it — a bio, a comment, markdown rendered to HTML — you have a stored or reflected XSS vector. Sanitize with a vetted library like DOMPurify before rendering, and avoid passing raw user input through it at all.
2. eval and the Function constructor
eval() runs whatever string you hand it, in your app's context, with your app's privileges. If any part of that string is attacker-influenced, that's remote code execution — the most severe class of bug there is. new Function(...) is the same hole. There's almost always a safe alternative: JSON.parse for data, a lookup table for dynamic dispatch, or a real expression evaluator if you genuinely need one. Treat any eval in a codebase as a bug until proven otherwise.
3. Wildcard CORS
Access-Control-Allow-Origin: * tells every website on the internet that it may read your API's responses. On a public, unauthenticated endpoint that's often fine. On any route that returns data behind cookies or an Authorization header, a wildcard origin quietly hands your users' data to any page they visit. The fix is an allowlist: reflect a specific set of trusted origins, and never pair * with credentials — browsers reject that combination anyway.
4. Unvalidated request bodies
request.json() returns any. Whatever an attacker POSTs, your handler receives — extra fields, wrong types, missing keys, deeply nested junk. That opens the door to mass-assignment and injection bugs that TypeScript gives you no protection against, because the types are a compile-time fiction the request never honored. Validate the parsed body against a schema — zod's safeParse is the common choice — and return 400 on failure before the data touches anything else.
Here's a route that gets two of these wrong at once — a wildcard CORS header and a body that flows straight into a query with no validation. This is live GuardLayer engine output, not a mockup:
- Warningapp/api/search/route.ts:2
API route without input validation
Validate the parsed body against a schema (e.g. zod safeParse) before use, and return 400 on failure. Consider rate limiting for unauthenticated routes. - Warningapp/api/search/route.ts:8
Permissive CORS configuration
Reflect a specific allowlist of trusted origins instead of '*', especially on routes that read cookies or Authorization headers.
5. Known-vulnerable dependencies
Plenty of breaches start in node_modules, not in your code. A transitive dependency pinned below its patched version is a known CVE sitting in your bundle, fully documented and trivially discoverable by anyone who reads your lockfile. Run npm audit, watch advisories for the packages you actually ship, and upgrade past the fixed version rather than muting the warning.
Two more that sit at the edge of the app layer
Two footguns straddle the line between app layer and auth, and both are commonly missed:
- Middleware with no matcher. A
middleware.tswith noconfig.matcherruns on every request — static assets, images, prefetches, everything. That's a performance problem, and if your auth logic is path-dependent, a correctness one too. - Server Actions with no auth check. A
"use server"function compiles to a public POST endpoint anyone can call. The ergonomics make it feel like an internal function; it isn't. Resolve and verify the user at the top of every action that mutates data.
The app layer produces real CVEs. In March 2025, CVE-2025-29927 revealed that Next.js middleware could be bypassed entirely with a crafted
x-middleware-subrequestheader — skipping the auth checks many apps run there. It was patched in Next.js 15.2.3, with back-ports for 12 through 14. Vercel-hosted apps were shielded automatically, but self-hosted deployments runningnext startwithoutput: standalonehad to upgrade or strip the header at the edge. Middleware is app-layer code, and app-layer code has a real attack surface.
A 60-second app-layer self-check
Run these against your repo:
# 1. Any raw HTML injection?
grep -rn "dangerouslySetInnerHTML" app/ components/
# 2. Any eval / Function constructor?
grep -rnE "\beval\s*\(|new Function\s*\(" app/ lib/
# 3. Any wildcard CORS?
grep -rn "Access-Control-Allow-Origin.*\*" app/
# 4. Any dependency below a known fix?
npm audit --production
A hit isn't automatically a vulnerability — a sanitized dangerouslySetInnerHTML or an intentionally public wildcard endpoint can be fine. But every hit deserves a deliberate answer, not a shrug.
If you'd rather not run four greps and read four docs, that's the gap GuardLayer fills: it scans a Next.js + Supabase repo for these app-layer issues (plus RLS and secrets) on every push and posts the findings as a PR comment. This pillar's spokes — XSS, eval, CORS, input validation, and dependencies — each map to a rule in that engine, and each has a deeper write-up if you want the full story.
FAQ
What is the "app layer" in a Next.js app? It's the code that runs between the incoming request and the outgoing response: route handlers, Server Actions, middleware, and the components that render the result. It sits above the database (where RLS lives) and separate from environment configuration (where secrets live). Bugs here are logic and input-handling bugs — XSS, injection, CORS, validation — rather than access-control or credential bugs.
Is Next.js secure by default?
Partly. React escapes rendered values by default, which prevents most XSS, and Vercel-hosted deployments got automatic protection against the CVE-2025-29927 middleware bypass. But the framework won't stop you from shipping wildcard CORS, an unvalidated request.json(), an eval, or a vulnerable dependency. "Secure by default" covers the framework's own surface, not the code you write on top of it.
Do I still need app-layer checks if my RLS is correct?
Yes. RLS protects rows in your database; it does nothing about an XSS payload in a comment, a wildcard CORS header leaking an authenticated response, or an RCE via eval. The layers are independent — a perfect RLS setup and a wide-open app layer is still a breach waiting to happen. Start with the full security checklist to see how the layers fit together.
How is app-layer security different from dependency scanning?
Dependency scanning (SCA) checks the third-party code you import against a database of known CVEs. App-layer security checks the code you wrote for insecure patterns. You need both: a clean dependency tree with an eval on user input is still exploitable, and hardened application code that imports a vulnerable package is still exposed.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.