Functional programming security: freeze helps, XSS still ships

A teal ice cube with a coral drip already on the saucer, house style.

CWE-79 is rank 1 and CWE-89 is rank 2 on the 2025 CWE Top 25. Neither row mentions a paradigm.A frozen cart still prints displayName as HTML if you skip escapeHtml. A pure builder still concatenates if you skip $1.

Use the XSS guide for the sink list, the injection guide for placeholders, and input validation for the door. Stay here when someone claims the paradigm is the control.

What freeze and purity actually buy

Immutability is a real control against a small class of bugs. A shared cart object that later handlers mutate is how a discount applied twice, a cents field overwritten after you thought it was final, or a request handler seeing another request’s items if you accidentally cached a mutable object. const does not freeze the object. It freezes the binding. Object.freeze in JavaScript is shallow. Nested lines still mutate unless you freeze those too or copy.

A pure function returns the same cents for the same input and does not write globalCart. That is testable. You can throw two fixtures at cartTotal without standing up Express. That is worth doing. Rank 1, rank 2, and a client-chosen key still sit on the sinks.

function freezeCart(cart) {
  const copy = {
    userId: cart.userId,
    lines: cart.lines.map((line) => Object.freeze({
      sku: line.sku,
      cents: line.cents,
      qty: line.qty,
    })),
  };
  return Object.freeze(copy);
}

function cartTotal(cart) {
  return cart.lines.reduce((sum, line) => sum + line.cents * line.qty, 0);
}

function applyDiscount(cart, offCents) {
  if (offCents < 0 || offCents > cartTotal(cart)) {
    throw new Error("discount out of range");
  }
  return freezeCart({
    userId: cart.userId,
    lines: cart.lines.map((line, i) => (
      i === 0
        ? { ...line, cents: line.cents - offCents }
        : line
    )),
  });
}

applyDiscount returns a new frozen cart. A later step cannot tack a script field onto lines[0] through that reference. A later step can still take displayName from the database and write it into HTML. Freeze never ran on that string.

FP habitHelpsDoes not close
Frozen cartShared mutable centsHTML sink, SQL grammar
Pure cartTotalCheap fixture testsCWE-639 on invoiceId
No global sessionCross-request bleed in-processCookie attached by the browser
Map and reduceReadable transformsinnerHTML on the result

What a paradigm never sees

I opened the 2025 CWE Top 25 table again for this page. Rank 1 is still Cross-site Scripting. Rank 2 is still SQL Injection. Rank 3 is CSRF. Rank 4 is Missing Authorization. Those are edges. Data leaves the process as markup, as a statement, as a cookie, or as a row lookup. A reduce over an array does not sit on those edges unless you put it there.

OWASP Top 10:2025 A05 is Injection. A01 is Broken Access Control. A07 is Authentication Failures. None of those categories say “prefer map to for.” Elm, Haskell, Clojure, and a Ramda-heavy React tree can still:

  • Build a <p> with string add and a user field.
  • Hand a concatenated statement to a driver that will run it.
  • Load a row by client-chosen invoiceId and skip userId.
  • Put a bearer token in localStorage where script can read it.

Declarative order does not remove those four. Shared-state lectures do not remove those four.Mutation of an in-memory ACL is a bug. The usual miss is the ACL never consulted, which is CWE-862, rank 4. Freeze the table if you want. Still call it.

Templates still do the XSS job

React’s default text interpolation encodes for an HTML body. That is a template rule, not a gift from purity. dangerouslySetInnerHTML is still a hatch with a loud name. Elm’s HTML library is the same idea: you do not get a raw string into the DOM unless you opt in. Go’s html/template context-encodes. A hand-rolled render(cart) that returns a string is none of those, even if cart is frozen and render is pure.

Identifiers stay displayName, invoiceId, and userId. If you render on the server in Express, you still need a named encoder or a template that encodes by default.

function escapeHtml(s) {
  return String(s)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

function renderProfile(displayName, cents) {
  return `<p>${escapeHtml(displayName)} &middot; ${escapeHtml(String(cents))}</p>`;
}

// BAD: pure, frozen input, still CWE-79
function renderProfileRaw(displayName, cents) {
  return `<p>${displayName} &middot; ${cents}</p>`;
}

renderProfileRaw will produce the same string every time you pass the same arguments. That is purity. It is also an XSS helper. The test you want is not “same input, same output.” The test is <em> stays text.

test("renderProfile keeps markup as text", () => {
  const html = renderProfile("<em>x</em>", 199);
  expect(html).toContain("&lt;em&gt;x&lt;/em&gt;");
  expect(html).not.toMatch(/<em>x<\/em>/);
});

Attribute sinks, javascript: URLs, and CSS are different encodings. Those live on the XSS flagship. Do not invent a second sanitizer here. textContent in the browser is the same idea when you are not using a template.

Purity does not bind SQL

A function that maps filters into a query string can be referentially transparent and still be CWE-89. The driver API is the control. pg wants text plus a values array. Knex wants bindings. Sequelize literal is the hatch. A compose-pipeline that ends in string add is the old miss with prettier arrows.

// Pure. Also text templating.
function sqlByName(displayName) {
  return "SELECT id, cents FROM invoices WHERE display_name = '" + displayName + "'";
}

async function queryInvoice(invoiceId, userId) {
  const { rows } = await pool.query(
    "SELECT id, display_name, cents FROM invoices WHERE id = $1 AND user_id = $2",
    [invoiceId, userId]
  );
  return rows[0] || null;
}

sqlByName is easy to test: pass O'Brien and you will see the quote in the returned string. That test should fail the function out of the repo. queryInvoice is the replacement. The two-key WHERE is CWE-639, rank 24, which freeze also does not see. An immutable invoiceId value is still a client-chosen key.

Zod 4.4.3, 4 May 2026, can prove displayName is a string of length at most 80 before you search. Do that. Then still bind. A typed string concatenates as easily as an untyped one.

A pure core under named sinks

The shape that works is a small pure core for money and policy math, then a thin edge that talks to the world. The edge is allowed to be impure. It is not allowed to skip the named sinks.

Freeze lives in the middle. The three misses live on the arrows out.
IN     parseBody displayName, invoiceId
       Zod 4.4.3 type and length

CORE   freezeCart
       cartTotal, applyDiscount
       same input, same cents

OUT    queryInvoice $1 $2          SQL
       canInvoice userId+id        row
       escapeHtml displayName      HTML

MISS   map into string-built SELECT
       map into raw <p>
       load by invoiceId only
       freeze never ran on those arrows
async function canInvoice(userId, invoiceId) {
  const { rows } = await pool.query(
    "SELECT 1 FROM invoices WHERE id = $1 AND user_id = $2",
    [invoiceId, userId]
  );
  return Boolean(rows[0]);
}

app.get("/invoices/:invoiceId", async (req, res) => {
  const { userId } = req.session;
  const { invoiceId } = req.params;
  if (!userId) {
    res.status(401).send("Unauthorized");
    return;
  }
  if (!(await canInvoice(userId, invoiceId))) {
    res.status(404).send("Not found");
    return;
  }
  const row = await queryInvoice(invoiceId, userId);
  if (!row) {
    res.status(404).send("Not found");
    return;
  }
  const cart = freezeCart({
    userId,
    lines: [{ sku: "inv", cents: row.cents, qty: 1 }],
  });
  res.set("Content-Type", "text/html; charset=utf-8");
  res.send(renderProfile(row.display_name, cartTotal(cart)));
});

The handler is impure. That is fine. freezeCart and cartTotal sit in the middle and are easy to test. canInvoice, queryInvoice, and renderProfile sit on the arrows. A rewrite that only extracts cartTotal and leaves res.send(\`<p>${row.display_name}\`) has not closed rank 1.

Prove the layer this week

You are checking which layer you actually have. You are not proving a language is safe.

  1. Unit-test cartTotal and applyDiscount with two fixtures. That is the purity win. Keep it.
  2. Search for string-built HTML: rg -n "res\\.send\\(`|<p>\\$\\{|innerHTML|dangerouslySetInnerHTML" --glob '!node_modules'.
  3. Search for string-built SQL: rg -n "WHERE .*\\+|whereRaw|Sequelize\\.literal" --glob '!node_modules'.
  4. Search for one-key loads: rg -n "WHERE id = \\$1" --glob '!node_modules' and ask which of those also have user_id.
  5. Render a profile whose displayName is <em>x</em>. Expect escaped text. A 200 with italics means the template never ran.
test("user A cannot read user B invoice", async () => {
  const agentA = await loginAs("userA");
  const denied = await agentA.get("/invoices/" + invoiceIdOfB);
  expect(denied.status).toBe(404);
});

curl -sS -D - -o /tmp/me.html \
  -H "Cookie: __Host-session=USER_A_SID" \
  "https://your-app.example/me"
# Expect: &lt;em&gt; in the body if displayName was <em>x</em>
# Expect: no live <em> node for that field

Helmet 8.3.0, 12 July 2026, still does not encode displayName. Keep the header pack. Do not count it as escapeHtml. A CSP is a belt. The encoder is the first control.

Questions we keep getting

Does Haskell or Elm close XSS by existing?

They make a raw HTML sink louder, which is good. They do not remove the need to encode at that sink, or the need to bind SQL in the IO layer, or the need to check userId. Treat the loud API as a name, then write the deny test anyway.

Is Object.freeze enough on a Node process?

It is shallow. It does not run across a JSON parse. A request body is a new object every time. Freeze copies you keep in memory. Parse with Zod on the way in. Encode on the way out.

Should we rewrite the app in a functional language for security?

Not as the close for rank 1 or rank 2. Rewrite if you want the type story and the team can operate it. Ship escapeHtml, queryInvoice, and canInvoice on the stack you have this quarter.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.