A Next.js document lives in a browser origin. A React Native screen lives in a device. Both UIs are untrusted. Both still talk to the same Hono / Better Auth API. This note is the origin.
The category map is Web Security and the OWASP Top 10. The device host is Security in React Native. This is a defensive reading of Next.js 16 App Router: failure modes and attacker goals, not exploits.
1. The Host
A Next.js request leaves through the origin, then meets the same server a native client uses:
Next.js:
Document / RSC → HttpOnly cookie → Server Action POST
Server Action POST → Hono API → Authn then authz| Next.js | React Native | |
|---|---|---|
| Isolation unit | Browser origin | App ID / keychain access group |
| Session | HttpOnly; Secure; SameSite cookie | SecureStore / Keychain / Keystore |
| XSS surface | DOM, dangerouslySetInnerHTML, next/script | WebView plus any JS bridge you expose |
| Secrets | server-only modules; NEXT_PUBLIC_* is public | Nothing in the binary is secret |
| Deep entry | searchParams, open redirects, router.push | Custom URL schemes, universal links, push payloads |
- The shared lie is the same on both hosts: the UI is not authorization. Hiding a button or checking a role in a Client Component does not decide whether a row may be read.
- Understanding Next.js in Depth covers the rendering machinery; Local Storage, Session Storage, and Cookies covers why the cookie jar is a different primitive from Web Storage.
2. The Server/Client Graph
Next.js compiles one source tree into several module graphs. 'use client' is a bundle boundary, not a security flag.
- Everything that module imports — and everything those imports import — can appear in a browser chunk. A secret that crosses that boundary is a leak at build time.
import "server-only"makes the contract explicit. A client import then fails the build instead of shipping the database URL.NEXT_PUBLIC_*is a rebuild-time public constant, inlined into client assets. Anything that must stay secret is read at runtime on the server, from SST Secrets or the function environment.- RSC props serialize across the same boundary. Pass the public view the UI needs: a display name, not a session, a Stripe customer id, or a role the client will "enforce."
- React's
experimental_taintUniqueValueandexperimental_taintObjectReferencecan fail the render if a tainted secret would otherwise serialize. They are a belt. They do not replace refusing to pass the value.
import "server-only"
export async function getBillingAccount(organizationId: string) {
return db.query.billingAccounts.findFirst({
where: (table, { eq }) => eq(table.organizationId, organizationId),
})
}Failure: 'use client' too high in the tree. A fatter graph widens XSS blast radius: more JavaScript on the origin, more chance a later Markdown renderer or analytics snippet runs there.
3. Server Actions Are Public POSTs
'use server' does not mean "only this form can call this." The build creates a server reference. The browser POSTs that reference plus serialized arguments.
- From a security perspective the action is an HTTP endpoint that happens to refresh UI.
- Next.js adds transport defenses: same-origin checks, encrypted closed-over values,
serverActions.allowedOrigins, body-size limits. Those reduce CSRF and confused-proxy cases. They do not decide whether this session may rename this product. - Bound arguments and closed-over IDs are not capabilities. An opaque action id is not a secret.
- Authorize beside the write, after parsing input, every time.
"use server"
const Input = z.object({
id: z.string().uuid(),
name: z.string().trim().min(1).max(120),
})
export async function renameProduct(formData: FormData) {
const actor = await requireEditor()
const input = Input.parse(Object.fromEntries(formData))
await db.product.update({
where: { id: input.id, tenantId: actor.tenantId },
data: { name: input.name },
})
}requireEditor()reads the session cookie on the server. The Client Component that rendered the form is not part of the trust decision.- TypeScript on the form fields is not a parser. Zod is. Tenant scope on the
whereis authorization, not a courtesy filter. - Set
serverActions.allowedOriginswhen the app sits behind a reverse proxy whose host is not the public origin. The longer argument lives in the Next.js note. The security sentence is short: reachable means callable.
4. proxy.ts Is a Cheap Gate
In Next.js 16, root-level proxy.ts replaces middleware.ts. It runs before the route tree.
- Right place for redirects, rewrites, extra headers, and coarse "is there a session cookie at all?" checks.
- Wrong place to be the only authorization. Matchers miss paths. Rewrites change what the app thinks the URL is.
- A cookie-presence check that redirects to
/loginimproves UX. It does not prove the user may load/organizations/[id]/billing. That proof belongs next to the data — in the Server Action, the Route Handler, or the Hono API behind them.
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function proxy(request: NextRequest) {
const session = request.cookies.get("session")
const isApp = request.nextUrl.pathname.startsWith("/app")
if (isApp && !session) {
return NextResponse.redirect(new URL("/login", request.url))
}
return NextResponse.next()
}
export const proxyConfig = {
matcher: ["/app/:path*"],
}Failure: copying identity into a request header here and then trusting that header in a Server Component. The session cookie is the identity. Anything proxy.ts adds is still just a header. A missing matcher on /app/export or a Route Handler under /api is not a theoretical gap.
5. Cache vs Privacy
Next.js will happily cache a personalized tree if you let it. A shared CDN entry that includes another user's HTML or RSC payload is a privacy incident that looks like a performance win.
- Reading
cookies(),headers(), or a user-specificsearchParamsbinds the work to this request. That output must not become one object for every visitor. - Shared catalog fields can be cached. Recommendations, dashboards, and anything keyed by session stay dynamic — or they are cached with a key that is actually private and a
Varythe CDN honors. - Split the tree: public catalog under
'use cache'or ISR; the session-shaped region stays a request-time Server Component that reads the cookie and passes display fields into the client view, not the session. - Source maps follow the same audience rule. Error tracking may need them. A public
/.mapdump is a source leak.
Failure: caching the wrong audience, not "forgot revalidateTag." A full-route cache of /account is a cross-user leak waiting for a hit. If it would be wrong to show user A's page to user B, it does not belong in a shared cache.
6. XSS in React and Next.js
React escapes text children. {user.name} in a <p> is data. The remaining holes are the places the app opts out.
dangerouslySetInnerHTML— CMS HTML, Markdown pipelines, and "rich text from the API" are the usual sources. If the product truly needs markup, sanitize with a maintained library on the server before it reaches the client, then still assume a miss.next/scriptand inline snippets — third-party tags are script on your origin. Prefernext/scriptwith an explicit strategy, and keep the allowlist of hosts in CSP.- User-controlled
href,src, androuter.push— a non-http(s) scheme in a destination is an injection into the page context. Do not concatenatesearchParamsinto those APIs. - Hydration is not a sanitizer. Markup that is unsafe on the server is unsafe after hydrate.
import "server-only"
import { JSDOM } from "jsdom"
import createDOMPurify from "dompurify"
const purify = createDOMPurify(new JSDOM("").window)
export function sanitizeArticleHtml(html: string) {
return purify.sanitize(html, { USE_PROFILES: { html: true } })
}- CSP is a backup, not a substitute for not rendering untrusted HTML. The header set in the OWASP note is the starting point. Trusted Types shrink remaining
innerHTMLsinks where the browser supports them.
Failure: treating sanitization as a license to skip CSP. dangerouslySetInnerHTML should be a review comment every time.
7. Untrusted Navigation
searchParams, callback URLs, and next/image sources are attacker-controlled input that happens to look like routing.
- Open redirects: a
?next=parameter passed toredirect()orrouter.pushwill send the user wherever the link said. - Allowlist paths on this origin, reject protocol-relative URLs, and default to a known-safe location.
searchParamsused as a query, a filter, or an id still belong in Zod on the server. The URL is not a trusted database.next/imagewill fetch remote URLs you allow.remotePatternsis an allowlist against using the optimizer as an open proxy — prefer a specific host. Local static imports do not need this; user-uploaded or CMS images do.
const ALLOWED_NEXT = new Set(["/app", "/settings", "/billing"])
export function safeNextPath(value: string | undefined) {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return "/app"
}
return ALLOWED_NEXT.has(value) ? value : "/app"
}Failure: a public ?preview=true that skips auth because a Server Component checked the query string. That is insecure design, not a routing trick. A wildcard hostname in remotePatterns is the same class as fetching a caller-supplied URL from a Server Action.
8. Defaults
- Secrets stay behind
server-only.NEXT_PUBLIC_*is for public identifiers. RSC props are a public API. - Server Actions and Route Handlers authenticate and authorize on the server, after Zod.
proxy.tsis a cheap cookie gate, not the lock. - Personalized HTML and RSC never go in a shared cache.
- Text is data. Markup is a review. Navigation and
remotePatternsare allowlists. CSP backs the origin; it does not define it.
The origin, the session, and the authorization check are the product. The renderer is not. The same sentence on device is Security in React Native.