Where the trust boundary is
In the App Router, Server Components, server actions and route handlers run on the server and can reach secrets and databases. Client Components run in the browser. The Next.js documentation says Client Components must follow the same security assumptions as code running in the browser, and that environment variables are only available on the server unless they are prefixed with NEXT_PUBLIC_.
The boundary is crossed in two directions. Data goes out when a Server Component passes props to a Client Component, and everything in those props reaches the browser. Requests come in through pages, route handlers and server actions. The documentation says an exported server action is reachable by a direct POST request, not only through your interface. So the question for every entry point is the same: what happens when a stranger calls this directly?
This page covers the framework. For where you host it, see the checklist for your platform, and for the general idea of why browser code cannot enforce access, see our guides on environment variables and client-side secrets.
The checklist
| Area | What to verify | How to test on your own project | Pass condition |
|---|---|---|---|
| Server actions | Each action verifies the session and the ownership of the record inside the action. | List files containing use server and read the first lines of every exported function. | Every action that reads or changes private data checks the user itself. |
| Route handlers | Each route.ts checks the caller and returns 401 or 403 when it should. | Call every API route with curl and no cookie, using the example in Step 2. | Private routes refuse anonymous calls. |
| Proxy or middleware | It is used for redirects and early checks, not as the only protection. | Read proxy.ts or middleware.ts and its matcher, then check that the routes it skips are protected elsewhere. | Removing the file would not expose any data, because checks also exist next to the data. |
| Public variables | No secret uses the NEXT_PUBLIC_ prefix. | Search env files and the built output with the commands in Step 3. | Only values that are safe to publish carry the prefix, and no secret string is in .next/static. |
| Data sent to the client | Client Components receive only the fields they display. | Read the props passed from Server to Client Components and view the page source for extra fields. | No password hash, token, internal id or other user's data appears in the page payload. |
| Cross-user access | Changing an id in a URL or action argument does not reach another user's record. | Sign in as a second test user and request the first user's record id. | The response is a refusal or an empty result. |
| Input validation | Params, search params, form data and headers are validated on the server. | Send unexpected and oversized values with curl, bypassing the form. | The server rejects them with a clear error. |
| Headers | A Content Security Policy and other security headers are set on purpose. | Run curl -I against your own deployed address and read the response headers. | The headers you chose are present on HTML responses. |
Step 1: Audit server actions
The Next.js documentation says a page-level authentication check does not extend to the server actions defined within it, and that you should always re-verify inside the action. It lists built-in protections, such as encrypted action ids and removing unused actions from the build, and then says you should still treat server actions as reachable via direct POST requests.
Find every action and read it. The documented pattern is to check authentication first, then authorisation, meaning that this user may act on this specific record. The auth and db helpers below stand in for whatever your project uses.
grep -rln "use server" app src
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const post = await db.post.findUnique({ where: { id: postId } })
if (!post || post.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.post.delete({ where: { id: postId } })
}Step 2: Call route handlers from outside
The documentation says to treat route handlers with the same security considerations as public-facing API endpoints. List them, then call each one against your own site with no cookie. Follow up with the two-account test: sign in as a second user, copy the first user's record id and request it.
find app src -name "route.ts" -o -name "route.js"
export SITE="https://your-site.example"
curl -i "$SITE/api/YOUR_ROUTE"
curl -i -X POST "$SITE/api/YOUR_ROUTE" -H "Content-Type: application/json" -d '{}'Step 3: Search for secrets in the build
Build the project and search the static output, which is what browsers download. Any hit for a secret-shaped string is a finding. Then list the public variables and confirm each is something you are content to publish. Adjust the patterns to your providers.
To stop server-only code being imported into client code by accident, the documentation recommends marking modules with the server-only package, which causes a build error if the module is imported in the client environment.
npm run build
grep -rEl "sk_live_|sb_secret_|service_role|BEGIN PRIVATE KEY" .next/static
grep -rn "NEXT_PUBLIC_" .env* 2>/dev/null
git ls-files | grep -E "^\.env"
git log --all -p -S"sk_live_" | head -50Step 4: Check what you send to the client
The Next.js documentation recommends a data access layer for new projects: a server-only module that performs authorisation checks and returns minimal objects with only the fields the interface needs. It shows the opposite as a warning, where a whole database row is passed to a Client Component and every field is exposed.
AI-generated code often takes the short path and passes the whole row. View the page source or the network responses on a page showing user data and look for fields that are not on screen. Server action return values are sent to the client too, so return a small result instead of the raw record.
Common mistakes
- Protecting the page and forgetting the server action inside it.
- Relying on a matcher in proxy or middleware, then adding a new route it does not cover.
- Returning null from a layout for unauthorised users. The docs say this does not stop nested routes and server actions from being accessed.
- Putting a secret in a NEXT_PUBLIC_ variable so a Client Component can call an API directly.
- Trusting a user id, role or price sent from the browser.
- Passing a full database row as props.
- Checking that the user is signed in but not that the record belongs to them.
Keep it working
Repeat the use server search and the route probes after every large generated change, since new entry points appear quietly. The documentation's own audit advice is to spend extra time on proxy.ts and route.ts, on use server files and on the props of use client files. Keep two test accounts for regression checks.
Frequently asked questions
Is Next.js secure by default?
Partly. It has sensible defaults, such as keeping environment variables on the server unless they are prefixed with NEXT_PUBLIC_, and built-in protections for server actions. It does not write your authorisation checks. The documentation says to verify authentication and authorisation inside each server action and route handler.
Are Next.js server actions public endpoints?
Yes, treat them that way. The Next.js documentation says an exported server action is reachable via a direct POST request, not just through your application's interface, and that you should treat them with the same security considerations as public-facing API endpoints.
Is middleware enough to protect routes in Next.js?
No. The authentication guide says Proxy, the current name for middleware, should not be your only line of defence and that most checks should happen as close as possible to your data source. Use it for redirects and early filtering, and keep real checks in your data access code.
Are NEXT_PUBLIC_ environment variables safe?
They are public. Next.js exposes any variable with that prefix to the browser, so use it only for values you would be content to publish, such as a publishable key or a public URL. API secrets and database credentials must never carry it.
Does Next.js protect against CSRF?
For server actions, to a degree. The documentation says they only accept POST and that Next.js compares the Origin header to the Host header and aborts the request if they do not match. The documentation describes that check for server actions only, so confirm in your own project how route handlers that change data and rely on cookies are protected.