SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 15, 2026·Security·SecureStartKit Team

Next.js CSRF, XSS, SQLi: The 3-Layer Defense [2026]

CSRF, XSS, and SQL injection prevention in Next.js. Three architectural defenses tied to OWASP A05:2025 and the 2026 Next.js injection CVEs.

Summarize with AI

On this page

  • Table of contents
  • Why does Next.js need three separate injection defenses?
  • How does Next.js prevent CSRF in Server Actions?
  • How do you prevent XSS in the App Router?
  • How does the Supabase client prevent SQL injection (and where does it fail)?
  • What about prompt injection in AI-integrated apps?
  • Five injection mistakes that ship in default templates
  • What this means for your Next.js + Supabase architecture

On this page

  • Table of contents
  • Why does Next.js need three separate injection defenses?
  • How does Next.js prevent CSRF in Server Actions?
  • How do you prevent XSS in the App Router?
  • How does the Supabase client prevent SQL injection (and where does it fail)?
  • What about prompt injection in AI-integrated apps?
  • Five injection mistakes that ship in default templates
  • What this means for your Next.js + Supabase architecture

CSRF, XSS, and SQL injection prevention in Next.js comes down to three architectural defenses: Origin-against-Host comparison on every Server Action (CSRF), nonce-based Content Security Policy with strict-dynamic (XSS), and a backend-only data access layer that routes every query through the auto-parameterized Supabase client (SQL injection). Each of the three categories has at least one 2026 Next.js CVE attached to it [4][5], and OWASP Top 10:2025 still lists injection as A05 with 38 underlying CWEs across SQL, command, and cross-site script vectors [1].

This post is the architectural pillar for the three classical injection categories. The supporting clusters cover specific surfaces in more depth: the 12-step Next.js security hardening checklist covers the broader hardening surface, the Server Actions + Zod guide covers input validation, and the backend-only data access pattern covers the data layer that makes SQL injection structurally improbable.

TL;DR:

  • CSRF defense: Next.js Server Actions compare the Origin header to the Host header on every POST and reject mismatches [2]. The bypass class is null-origin requests (sandboxed iframes), which CVE-2026-27978 patched in Next.js 16.1.7. Add serverActions.allowedOrigins when running behind a proxy.
  • XSS defense: nonce-based CSP via the proxy layer (renamed from middleware in Next.js 16), with script-src 'self' 'nonce-XXX' 'strict-dynamic'. Static and ISR-rendered routes need a hash-based fallback because they cannot generate per-request nonces. CVE-2026-44581 turned the nonce mechanism itself into an XSS vector in default App Router configurations [5].
  • SQL injection defense: the Supabase JavaScript client talks to PostgREST, which parameterizes every query automatically. The risk surface is custom RPC functions using EXECUTE with string concatenation and direct postgres-js connections that build raw SQL. The architectural fix is to never write raw SQL from application code.
  • The fourth injection class in 2026: prompt injection through service_role-scoped AI agents. Not in OWASP A05 yet, but already responsible for the Supabase MCP exposure class. The same backend-only boundary that fixes SQL injection fixes this too.
  • The frame: every injection category has a Next.js-specific 2026 CVE. Each has a default-deny architectural pattern that prevents the category, not just the specific CVE. The defaults are the defense.

Table of contents

  • Why does Next.js need three separate injection defenses?
  • How does Next.js prevent CSRF in Server Actions?
  • How do you prevent XSS in the App Router?
  • How does the Supabase client prevent SQL injection (and where does it fail)?
  • What about prompt injection in AI-integrated apps?
  • Five injection mistakes that ship in default templates
  • What this means for your Next.js + Supabase architecture

Why does Next.js need three separate injection defenses?

OWASP Top 10:2025 lists Injection as A05, down two spots from A03 in the 2021 edition, but the underlying surface has grown rather than shrunk. The category now spans 38 CWEs covering SQL injection, command injection, cross-site scripting, OS command injection, and LDAP injection, with 100% of tested applications exhibiting some form of injection vulnerability during testing [1]. The rank drop reflects relative improvement in tooling, not absence of the class.

Next.js applications inherit three distinct injection surfaces because the framework spans three execution contexts:

  • The HTTP boundary between the browser and Server Actions or Route Handlers, where forged cross-site requests can submit mutations as the logged-in user. This is the CSRF surface.
  • The rendering boundary between server-rendered HTML and the user's browser, where attacker-controlled strings inserted into a Server Component or Client Component can execute as script. This is the XSS surface.
  • The data boundary between application code and PostgreSQL, where unparameterized string concatenation in RPC functions or direct database connections can let user input become SQL syntax. This is the SQL injection surface.

Each surface has at least one disclosed 2026 Next.js CVE attached to it. CVE-2026-27978 patched a CSRF bypass via null-origin requests in Next.js 16.1.7 [4]. The May 2026 coordinated release patched CVE-2026-44581 (XSS via CSP nonces) and twelve other advisories [5]. The Supabase MCP exposure class from earlier in 2026 was effectively an injection vector through prompt-controlled service_role queries. Three categories, three defense layers, three architectural decisions.

The thesis: the architectural defaults that prevent each category are well known. Templates that ship them by default are rare. The rest of this post walks the three defenses in the order they appear in a request lifecycle.

How does Next.js prevent CSRF in Server Actions?

Cross-Site Request Forgery (CSRF) is the attack where an attacker tricks a logged-in user's browser into submitting a mutation request to your application. The user's session cookie rides along automatically; the server has no way to distinguish a forged request from a legitimate one unless it explicitly checks the request's origin.

Next.js Server Actions ship with two layers of CSRF defense out of the box:

Origin-vs-Host comparison on every POST. The framework compares the Origin header (set by the browser, not the user) to the Host header (or X-Forwarded-Host when behind a proxy). If they don't match, the request aborts before reaching your action code [2]. This catches the standard CSRF attack class because a malicious site can't fake an Origin header pointing to your domain.

POST-only mutations. Server Actions can only be invoked via POST. This eliminates the simplest CSRF vector (an image tag pointed at a mutation URL) because GET requests cannot trigger mutations. Combined with the SameSite=Lax cookie default in modern browsers, the surface for naive CSRF is small.

The configuration matters in two cases. First, when the app sits behind a reverse proxy (Vercel, Cloudflare, a custom load balancer), the Host header may not match the public origin. Set the trusted origins explicitly:

// next.config.ts
const nextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: ['app.example.com', '*.app.example.com'],
    },
  },
}

export default nextConfig

Second, the null-origin case. CVE-2026-27978 disclosed that Next.js was treating Origin: null (sent by sandboxed iframes and a few other opaque contexts) as a missing header rather than as a hostile cross-origin signal [4]. Attackers could embed your application in a sandboxed iframe and submit Server Action requests that bypassed the origin check entirely. The fix shipped in Next.js 16.1.7, and the lesson generalizes: framework-level CSRF protection is necessary but not sufficient. Two further patterns close the gap.

Defense in depth: authorization inside every action. The Next.js documentation states explicitly: "Always verify authentication and authorization inside each Server Action, even if the form is only rendered on an authenticated page" [6]. The pattern is the validate-then-authorize-then-query sequence from the Server Actions + Zod guide: a Zod safeParse on the input, a getUser() call to identify the session, and an admin-client database operation gated behind both checks. CSRF that bypasses the framework still has to pass the authorization check inside the action.

Rate limiting on sensitive actions. Brute-force CSRF can be slowed to uselessness with per-IP and per-user rate limits. The rate limiting guide covers the in-memory and Redis patterns. A CSRF attacker that gets one request per ten seconds against a login endpoint has a different problem than one that gets ten thousand per second.

Upgrade Next.js to 16.1.7 or newer, set serverActions.allowedOrigins for proxy environments, validate inputs with Zod, authorize inside every action, and rate-limit sensitive endpoints. That stack survives the disclosed 2026 CSRF class.

How do you prevent XSS in the App Router?

Cross-Site Scripting (XSS) is the attack where attacker-controlled content gets rendered as executable script in a victim's browser. React's default JSX rendering escapes string interpolation, so the trivial XSS case (a <div>{userInput}</div> rendering a script tag) doesn't fire. The non-trivial cases involve React's raw-HTML insertion prop, content delivered through Markdown or MDX without sanitization, or attacker-controlled URLs in href attributes that allow javascript: schemes.

The defense in 2026 is a strict Content Security Policy (CSP) with nonces, applied through the proxy layer (renamed from middleware in Next.js 16). The Next.js documentation walks the canonical pattern [3]:

// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')

  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `
    .replace(/\s{2,}/g, ' ')
    .trim()

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', cspHeader)

  const response = NextResponse.next({ request: { headers: requestHeaders } })
  response.headers.set('Content-Security-Policy', cspHeader)

  return response
}

export const config = {
  matcher: [
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

The directives that matter for XSS specifically:

  • default-src 'self' blocks every resource type from external origins unless an explicit directive allows it. This is the deny-all baseline.
  • script-src 'self' 'nonce-XXX' 'strict-dynamic' allows scripts from the same origin and any script tagged with the matching nonce. strict-dynamic lets nonce-loaded scripts spawn further scripts without explicit nonces on each (necessary for code-splitting). Without strict-dynamic, Next.js bundles break.
  • object-src 'none' blocks Flash and other legacy plugin content entirely.
  • frame-ancestors 'none' is the CSP equivalent of X-Frame-Options: DENY and blocks clickjacking.
  • upgrade-insecure-requests forces every HTTP subresource to upgrade to HTTPS.

Three implementation gotchas. Static and ISR routes cannot use per-request nonces because the response body is generated before the request arrives. For those routes, fall back to a hash-based CSP listing the SHA-256 hashes of each inline script. unsafe-inline defeats the entire defense and should never appear in script-src. Third-party scripts must either be nonce-loaded server-side or hash-allowlisted explicitly, never blanket-allowed via wildcards.

The May 2026 release included CVE-2026-44581: an XSS vector in App Router applications that use CSP nonces with beforeInteractive scripts. The bug class is exactly the kind the CSP was supposed to prevent, and the fix shipped in Next.js 15.5.16 and 16.2.5 [5]. The pattern: framework-level XSS protection is a layer, not the whole stack. The complementary layers are sanitization at every render boundary that touches user content (especially Markdown and MDX), and avoidance of React's raw-HTML insertion prop unless the input was sanitized with a library like DOMPurify on the server side before reaching the renderer.

The free Next.js security headers generator produces a complete header set including the CSP, and the CORS config generator handles the cross-origin policy for API routes. Both are calibrated for the default-deny posture this post recommends.

How does the Supabase client prevent SQL injection (and where does it fail)?

The Supabase JavaScript client never sends raw SQL from the application. Every query method (from, select, eq, filter, rpc) compiles into an HTTP request to PostgREST, which then issues a parameterized SQL query against PostgreSQL. The parameterization happens at the PostgREST layer, before the query touches the database. User input arrives as values in URL parameters or JSON bodies; it never gets concatenated into the SQL string [3].

This means the canonical application pattern is structurally safe:

import { createAdminClient } from '@/lib/supabase/server'

const admin = createAdminClient()

// Safe: 'userId' goes into a parameterized WHERE clause via PostgREST
const { data } = await admin
  .from('profiles')
  .select('display_name, avatar_url')
  .eq('id', userId)
  .single()

There is no way to make userId execute as SQL through this API. The PostgREST layer enforces a separation between query structure (defined by the JS method chain) and query values (the operands passed to .eq, .in, .gte, and so on). This is the architectural reason the backend-only data access pattern eliminates an entire vulnerability class: applications that only query through the Supabase client cannot SQL-inject themselves.

The injection surface that remains is everything outside this layer:

Custom RPC functions using EXECUTE with string concatenation. A PostgreSQL function that builds dynamic SQL with EXECUTE 'SELECT * FROM ' || table_name is injectable. The fix is EXECUTE format(...) USING ... with parameterized arguments:

-- INJECTABLE: do not write this
CREATE FUNCTION search_user(table_name text, user_id text)
RETURNS SETOF record AS $$
BEGIN
  RETURN QUERY EXECUTE 'SELECT * FROM ' || table_name
    || ' WHERE id = ''' || user_id || '''';
END
$$ LANGUAGE plpgsql;

-- SAFE: parameterized via USING
CREATE FUNCTION search_user(user_id uuid)
RETURNS SETOF profiles AS $$
BEGIN
  RETURN QUERY SELECT * FROM profiles WHERE id = user_id;
END
$$ LANGUAGE plpgsql;

If dynamic table or column names are genuinely required, use format() with %I (identifier) and %L (literal) placeholders, and validate the input against an allowlist of acceptable names before calling the function.

Direct postgres-js or node-postgres connections. Edge Functions or background workers that bypass PostgREST and connect to PostgreSQL directly must use parameterized queries. The node-postgres pg library passes parameters as separate arguments that the PostgreSQL server substitutes after parsing the query string, which makes injection structurally impossible:

import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_URL!)

// Safe: ${userId} compiles to a parameter, not string concatenation
const result = await sql`
  SELECT id, display_name FROM profiles WHERE id = ${userId}
`

The ${userId} interpolation in the postgres-js tagged template literal is the safe form; the library converts it to a parameter, not to literal SQL. The unsafe form would be sql.unsafe('SELECT ... WHERE id = ' + userId), which exists specifically as an escape hatch and should never wrap user input.

Application-layer PostgREST filter strings. Methods like or() that accept filter strings can be misused if the filter is built from user input via concatenation. The safer pattern is to use individual filter methods chained together, with user input only ever as method arguments, never as substrings of method names or operator strings.

Architecturally: the rule that prevents the entire SQL injection class is "no raw SQL from application code." Every query runs through the Supabase client, RPC functions are parameterized via USING, and direct postgres-js connections use tagged template literals. The free SaaS security checklist tool audits these patterns before deploy, and the RLS policies that actually work guide covers the policy layer that limits damage if any of the above ever leaks.

What about prompt injection in AI-integrated apps?

Prompt injection isn't classical OWASP A05 yet, but it sits in the same architectural slot: untrusted input that gets interpreted as instructions by a downstream interpreter. In 2026, the disclosed Supabase MCP exposure class showed how an AI agent operating with the service_role key can be coerced into executing arbitrary SQL via crafted user messages. The agent reads customer-submitted content as input; that content contains instructions that the agent interprets as commands; the commands execute with full database privileges.

The defense pattern mirrors the three classical injection defenses:

Treat AI agent context as untrusted input. Anything that arrives from a user, an email, a webhook, a third-party API, or a database row populated from any of the above is hostile until proven otherwise. An AI agent that summarizes "tickets" must not run user-content-derived instructions as tool calls. Sandboxing the agent's tool surface to a narrow allowlist closes most of the prompt-injection-to-command-execution paths.

Never give an AI agent the service_role key. If an LLM agent runs against your Supabase project, give it a scoped role with explicit grants on a narrow schema, not the bypass-RLS service_role. The blast radius of a successful prompt injection becomes "what this role can do," not "every row in every table." The backend-only data access pattern generalizes here: service_role belongs in Server Actions you wrote, never in agents whose outputs you can't fully predict.

Validate AI outputs before they reach a side-effecting operation. If an LLM produces SQL, validate it against an allowlist of expected query shapes before executing. If it proposes a function call, parse the arguments against a Zod schema before dispatching. The same safeParse discipline that protects Server Actions from human-crafted input protects them from LLM-generated input.

OWASP added LLM-specific risks to the 2025 Top 10 lens through a separate companion list, but the architectural rules are the same as the classical injection categories: separate code from data, parameterize at the boundary, and validate everything that crosses a trust line.

Five injection mistakes that ship in default templates

These patterns appear in real Next.js + Supabase applications and would survive a generic code review. Each one type-checks.

1. Skipping serverActions.allowedOrigins behind a proxy.

Vercel deployments behind a custom domain, Cloudflare deployments, or any reverse-proxied setup may see a Host header that doesn't match the public origin. Without allowedOrigins, every Server Action returns 403 in production, and the panicked fix is often to disable the CSRF check entirely. The right fix is to set the allowlist:

experimental: {
  serverActions: {
    allowedOrigins: ['app.example.com'],
  },
}

2. Rendering Markdown via React's raw-HTML prop without sanitization.

Rendering Markdown from user input through React's raw-HTML insertion prop is the most direct XSS vector still in production code. The fix is to sanitize on the server side before the string reaches the renderer, using DOMPurify or a Markdown library with safe defaults (marked with sanitize: true is not enough; pass through DOMPurify). Better: never render user-controlled HTML at all. Convert to Markdown and render through a known-safe component tree.

3. Building RPC functions with EXECUTE and string concatenation.

A SECURITY DEFINER PostgreSQL function with dynamic SQL via EXECUTE 'SELECT ... ' || user_input runs with the function owner's privileges, which typically include bypassing RLS. The combination is a write-anywhere primitive triggered by user input. Use EXECUTE format(...) USING with parameter placeholders, or rewrite the function to not need dynamic SQL.

4. Relying on framework-level CSRF protection without authorization checks.

Server Action CSRF protection rejects forged cross-origin requests, but it does not validate whether the authenticated user is allowed to perform the requested operation. A user who is logged in and on the right origin can still call a Server Action that mutates someone else's data. The validate-then-authorize-then-query pattern from the Server Actions + Zod guide is what closes this.

5. Trusting client-side input sanitization to prevent server-side injection.

A common pattern is to escape user input on the client before submission ("we already sanitized it"). This is worthless against the server-side injection surface because the client is the attacker. Every form payload, every URL parameter, every webhook body, every third-party API response must be re-validated and re-sanitized at the server boundary, by code the server controls.

What this means for your Next.js + Supabase architecture

Three architectural decisions close the three classical injection categories by default:

Backend-only data access means the Supabase client (with PostgREST's automatic parameterization) is the only data path application code ever takes. Raw SQL doesn't exist in the codebase. RPC functions use USING parameters. The SQL injection surface collapses to "what the user-defined functions explicitly opt into," which is small enough to audit by hand.

Zod-everywhere validation at every Server Action means every payload is structurally validated before it reaches business logic. Combined with the framework-level Origin/Host check, the CSRF surface collapses to "what passes both an origin check and a schema check," which is the intended user input plus nothing else.

Strict CSP through the proxy or next.config.ts headers blocks external resources by default, restricts inline execution, and in the strictest nonce-based form eliminates inline scripts entirely. Combined with React's automatic escaping and explicit sanitization at the few places raw-HTML insertion legitimately appears, the XSS surface collapses to "what an attacker can do without injecting a script tag," which against a modern App Router app is nearly nothing.

The architecture that ships these three defaults is what we built SecureStartKit on: the Server Actions + Zod pillar on the input layer, the backend-only data access pattern on the data layer, the 12-step hardening checklist for the surrounding deployment surface, and the pre-launch security checklist for the final pre-deploy audit. The CSP header set, CSRF allowlist, and RPC parameterization patterns ship pre-wired. The injection categories that OWASP has tracked since 2003 are not unsolved problems; they are problems with well-known architectural solutions that most templates don't ship.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. A05:2025 - Injection— owasp.org
  2. How to Think About Security in Next.js— nextjs.org
  3. Guides: Content Security Policy— nextjs.org
  4. CVE-2026-27978: Next.js null origin can bypass Server Actions CSRF checks— advisories.gitlab.com
  5. Next.js May 2026 security release— vercel.com
  6. Guides: Data Security— nextjs.org

Related Posts

May 12, 2026·Security

The Secure SaaS Launch Checklist: 7 Non-Negotiables [2026]

Seven security checks every solo dev must verify before going live: auth, RLS, Zod, webhooks, headers, secrets, error handling. The pre-launch audit.

May 11, 2026·Security

The Security Architecture Most SaaS Templates Skip [2026]

Five architectural patterns most Next.js SaaS templates skip: backend-only access, Zod everywhere, RLS deny-all, signed webhooks, server-only imports.

May 9, 2026·Security

Backend-Only Data Access in Next.js + Supabase [2026]

The architectural pattern that prevents Supabase data leaks. Server Actions, admin client, no NEXT_PUBLIC key for queries, ever.