JavaScript / TypeScript/05advanced12 min

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 Probe on 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: dangerouslySetInnerHTML requires 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.

RiskDefense
Invalid data from the APIzod validation at the boundary
XSSno dangerouslySetInnerHTML or DOMPurify
Secret leakno NEXT_PUBLIC_, fetch from the server
Open redirecta 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.local is 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.