Short answer: Treat every Server Action and Route Handler as a public endpoint, authorize close to the data, keep secrets server-only, set session cookies on the server, and deploy a CSP that matches the rendering mode you actually use.
1. Mark server and client boundaries
Code imported by a Client Component can enter the browser bundle. Keep database clients, signing keys and privileged service calls in server-only modules. Environment variables prefixed for browser exposure must contain public values only. Inspect the production bundle rather than relying on file names.
Server Components reduce client JavaScript, but they do not create authorization by themselves. A direct request can still reach Route Handlers and Server Actions.
2. Authorize Server Actions and Route Handlers
Treat an exported Server Action like an externally callable mutation. Validate the submitted fields, load the current session, check the action permission, and scope the data lookup to the tenant. Repeating the check close to the data protects callers other than the UI.
'use server'
import 'server-only'
export async function renameProject(projectId, rawName) {
const session = await verifySession();
const name = validateProjectName(rawName);
const project = await db.project.findFirst({
where: { id: projectId, workspaceId: session.workspaceId }
});
if (!project || !session.can("project:update")) throw new Error("Not found");
await db.project.update({ where: { id: project.id }, data: { name } });
}3. Harden sessions, caching and redirects
Set authentication cookies from the server with HttpOnly, Secure, SameSite, a narrow path and an expiry. Rotate them at login and privilege changes. Validate post-login destinations against an internal allowlist to avoid open redirects.
Do not cache personalized responses as public. Review static rendering, revalidation and CDN keys wherever the result depends on cookies, headers, role or tenant. Return only fields the component needs.
4. Add a rendering-compatible CSP
A nonce-based policy needs a fresh nonce per response and therefore dynamic rendering. A hash-based policy can fit static assets but must be regenerated with the build. Start in report-only mode, exercise navigation, images and integrations, then enforce. Avoid broad wildcards and keep object-src 'none', base-uri 'self' and a restrictive frame-ancestors.
5. Review high-risk edges
Apply body-size limits before parsing uploads, verify actual file handling, and store files outside executable or static paths. For image proxies, preview fetches and webhooks, validate destinations and signatures. Middleware or Proxy can perform an optimistic session check, but the secure authorization decision belongs near the data source.
Common mistakes
- Assuming 'use server' means trusted caller.
- Checking authentication in a layout and omitting authorization in a mutation.
- Returning an ORM object with secret or internal fields to a Client Component.
- Putting credentials in a browser-exposed environment variable.
- Caching a tenant-specific response without the tenant in the cache boundary.
- Adding a nonce CSP while expecting full static rendering.
Checklist
- Server-only modules guard secrets and privileged clients.
- Every Server Action and Route Handler validates and authorizes.
- Queries include the current tenant or owner constraint.
- Session cookies use secure server-side options and rotate.
- Redirect targets are internal and validated.
- Personalized content is never publicly cached.
- CSP behavior is tested against the chosen rendering strategy.
- Uploads, webhooks and outbound fetches have dedicated controls.
Limits
Next.js security changes across versions and deployment adapters. Verify behavior against the documentation for the version and hosting platform you ship. Framework controls cannot correct an inaccurate tenant model or missing product-level permission rule.
Primary sources
Related resources
Review a Next.js repository
Apply these controls to the actual code and product boundaries.