Secure patterns (validation, XSS, secrets)
The ZEUS frontend is part of a security product — it must itself be secure. The three most common traps are a lack of validation of data from outside, XSS, and secrets leaking into the bundle. We devote this lesson to exactly those.
Validate everything that comes from outside
An API response, URL parameters, localStorage — these are data we don't
trust. We validate them with zod and only then use them.
import { z } from "zod";
const ProbeSchema = z.object({
id: z.string(),
host: z.string(),
forest: z.string(),
});
export async function getProbe(id: string) {
const res = await fetch(`/api/probes/${id}`);
const raw: unknown = await res.json();
return ProbeSchema.parse(raw); // throws if the shape doesn't match
}
ProfessNet standard: a boundary with the network/storage always passes through a zod schema. We don't cast
as Probeon raw JSON — that's a lie to the compiler.
XSS — don't inject raw HTML
React escapes content by default, so {userInput} is safe. The dangerous one
is dangerouslySetInnerHTML.
// safe — React escapes
<span>{probe.host}</span>
// dangerous — XSS if the content comes from a user
<div dangerouslySetInnerHTML={{ __html: probe.note }} />
If you really must render HTML (e.g. a description from markdown), sanitize it first:
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
ProfessNet standard:
dangerouslySetInnerHTMLrequires sanitization (DOMPurify) and a justifying comment in the PR. Without it review rejects the change.
Secrets — NEXT_PUBLIC_ is a deliberate decision
In Next.js, variables with the NEXT_PUBLIC_ prefix reach the bundle and are
visible in the browser. Everything else stays on the server.
// PUBLIC — visible to anyone in the browser
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// SECRET — server-side only (Server Component / Route Handler)
const apiKey = process.env.ZEUS_API_KEY; // no NEXT_PUBLIC_
// app/api/scan/route.ts — a secret used only on the server
export async function POST(req: Request) {
const res = await fetch("https://backend/scan", {
headers: { authorization: `Bearer ${process.env.ZEUS_API_KEY}` },
});
return Response.json(await res.json());
}
ProfessNet standard: an API key or token never has the
NEXT_PUBLIC_prefix. Calls that require a secret go through a Route Handler / Server Action, not from the browser.
| Risk | Defense |
|---|---|
| Invalid data from the API | zod validation at the boundary |
| XSS | no dangerouslySetInnerHTML or DOMPurify |
| Secret leak | no NEXT_PUBLIC_, fetch from the server |
| Open redirect | a whitelist of allowed URLs |
Security headers
In next.config.js we set CSP and basic headers:
headers: async () => [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
],
},
];
Tip:
.env.localis in.gitignore. In the repo we keep only.env.example. A secret in the git history = key rotation.
Three habits: validate input, don't inject raw HTML, keep secrets on the server. That's 90% of frontend security, and it costs a few extra lines of code.