Developer Guides
·IdenticAPI

PII Detection in Node.js

Integrate PII and secrets detection in Node.js — server-side API calls, redaction options, and placement in Express or Next.js request handlers.

PII detection in Node.js typically means calling a security API from your server-side request handler—Express middleware, Next.js Route Handlers, or server actions—before user text is logged or forwarded to an LLM provider, then branching on the returned verdict to allow, redact, or block the request. Server-side placement is essential because client-side checks are bypassable and because secrets in environment variables stay out of browser bundles.

This guide walks through a complete Node.js integration with IdenticAPI's PII & Secrets Detection API. For conceptual background, see What Is PII Detection? and How to Detect PII in Text with an API.

Prerequisites

  • Node.js 18+ (native fetch) or Node 16 with node-fetch
  • IdenticAPI API key stored in IDENTICAPI_API_KEY
  • TypeScript optional but recommended

Full API reference: PII & Secrets Detection docs.

Environment setup

export IDENTICAPI_API_KEY="idapi_test_your_synthetic_key_here"
export IDENTICAPI_BASE_URL="https://www.identicapi.com"

Never hardcode keys in source. Use .env locally with .gitignore coverage.

Core client module

Create lib/pii-secrets-client.ts (or .js):

const BASE_URL = process.env.IDENTICAPI_BASE_URL ?? "https://www.identicapi.com";
const API_KEY = process.env.IDENTICAPI_API_KEY;

export type PiiFinding = {
  category: string;
  reason: string;
  confidence?: number;
  start?: number;
  end?: number;
};

export type PiiSecretsResponse = {
  request_id: string;
  api: string;
  verdict: "safe" | "suspicious" | "unsafe";
  risk: "low" | "medium" | "high";
  confidence: number;
  findings: PiiFinding[];
  reasons: string[];
  redacted_text?: string;
  usage_units: number;
  processing_time_ms: number;
  detector_version: string;
};

export class PiiScanError extends Error {
  constructor(
    message: string,
    public readonly status?: number,
    public readonly body?: unknown
  ) {
    super(message);
    this.name = "PiiScanError";
  }
}

export async function scanPiiSecrets(
  text: string,
  options: { redact?: boolean } = {}
): Promise<PiiSecretsResponse> {
  if (!API_KEY) {
    throw new Error("IDENTICAPI_API_KEY is not configured");
  }

  const response = await fetch(`${BASE_URL}/api/v1/security/pii-secrets`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      text,
      redact: options.redact ?? false
    })
  });

  const body = await response.json().catch(() => ({}));

  if (!response.ok) {
    throw new PiiScanError(
      `PII scan failed: ${response.status}`,
      response.status,
      body
    );
  }

  return body as PiiSecretsResponse;
}

export type PrivacyAction = "allow" | "redact" | "block";

export function resolvePrivacyAction(verdict: PiiSecretsResponse["verdict"]): PrivacyAction {
  switch (verdict) {
    case "unsafe":
      return "block";
    case "suspicious":
      return "redact";
    default:
      return "allow";
  }
}

Express middleware example

import express from "express";
import { scanPiiSecrets, resolvePrivacyAction, PiiScanError } from "./lib/pii-secrets-client";

const app = express();
app.use(express.json({ limit: "32kb" }));

app.post("/api/chat", async (req, res) => {
  const userMessage: string | undefined = req.body?.message;

  if (!userMessage || typeof userMessage !== "string") {
    return res.status(400).json({ error: "message is required" });
  }

  let scan;
  try {
    scan = await scanPiiSecrets(userMessage, { redact: true });
  } catch (err) {
    if (err instanceof PiiScanError) {
      console.error("pii_scan_http_error", { status: err.status });
    } else {
      console.error("pii_scan_error", { message: String(err) });
    }
    // Fail closed: do not forward to LLM when scanner unavailable
    return res.status(503).json({
      error: "privacy_scan_unavailable",
      message: "Unable to verify message safety. Try again shortly."
    });
  }

  const action = resolvePrivacyAction(scan.verdict);

  if (action === "block") {
    console.warn("secrets_blocked", {
      request_id: scan.request_id,
      categories: scan.findings.map((f) => f.category)
    });
    return res.status(400).json({
      error: "secrets_detected",
      message: "Remove API keys, tokens, and private keys from your message.",
      request_id: scan.request_id
    });
  }

  const textForLlm =
    action === "redact" && scan.redacted_text ? scan.redacted_text : userMessage;

  if (action === "redact") {
    console.info("pii_redacted", {
      request_id: scan.request_id,
      categories: scan.findings.map((f) => f.category)
    });
  }

  // Replace with your LLM provider call using textForLlm — not userMessage
  const llmReply = await callLlm(textForLlm);

  return res.json({
    reply: llmReply,
    privacy: {
      action,
      request_id: scan.request_id
    }
  });
});

async function callLlm(prompt: string): Promise<string> {
  return `Echo (sanitized): ${prompt.slice(0, 120)}`;
}

app.listen(3000, () => console.log("Listening on :3000"));

Next.js App Router route handler

// app/api/chat/route.ts
import { NextResponse } from "next/server";
import { scanPiiSecrets, resolvePrivacyAction } from "@/lib/pii-secrets-client";

export async function POST(request: Request) {
  const { message } = (await request.json()) as { message?: string };

  if (!message) {
    return NextResponse.json({ error: "message is required" }, { status: 400 });
  }

  const scan = await scanPiiSecrets(message, { redact: true });
  const action = resolvePrivacyAction(scan.verdict);

  if (action === "block") {
    return NextResponse.json(
      {
        error: "secrets_detected",
        request_id: scan.request_id
      },
      { status: 400 }
    );
  }

  const prompt = scan.redacted_text ?? message;
  // await streamFromLlm(prompt) ...
  return NextResponse.json({ prompt, action, request_id: scan.request_id });
}

Place scanning in server routes only—never expose your API key to the browser. For a unified privacy module design, see Building a Privacy Filter Before Your LLM API Call.

Testing with synthetic data

import { scanPiiSecrets } from "./lib/pii-secrets-client";

async function runTests() {
  const cases = [
    { text: "Hello!", expectVerdict: "safe" },
    { text: "Email user@example.com", expectVerdict: "suspicious" },
    {
      text: "Key sk-test_abcdefghijklmnopqrstuvwxyz123456",
      expectVerdict: "unsafe"
    }
  ];

  for (const c of cases) {
    const result = await scanPiiSecrets(c.text);
    console.log(c.text.slice(0, 40), result.verdict, result.verdict === c.expectVerdict ? "OK" : "FAIL");
  }
}

runTests().catch(console.error);

Validate interactively with the PII Checker.

Error handling and timeouts

Wrap fetch with AbortSignal.timeout(5000) to avoid hanging chat requests if the scanner is slow. Log request_id on success for audit trails—never log raw user text when verdict is not safe.

Choose fail closed (block LLM) vs fail open (allow with alert) explicitly. Fail closed aligns with PII and Secrets Leakage Checklist recommendations for production.

Limitations

  • Network dependency adds latency vs in-process regex
  • 32,000-character limit—split large documents before scanning
  • Pattern detection misses obfuscated PII
  • This code does not scan RAG context or chat history automatically—assemble full prompt server-side and scan once

Python equivalent: PII Detection in Python.

Frequently asked questions

Where should PII detection run in a Node.js LLM app?

In server-side Express middleware, Next.js Route Handlers, or server actions—before logging or calling the LLM provider. Never expose your IdenticAPI key to the browser.

What Node.js version do I need?

Node.js 18+ includes native fetch. For Node 16, use node-fetch or undici. Store IDENTICAPI_API_KEY in environment variables.

Should the scanner fail open or closed on errors?

Fail closed (block LLM calls) aligns with stronger privacy posture for production. Fail open with alerting is lower friction but higher risk—document your choice explicitly.

Does one scan per message cover chat history?

Only if you assemble the full prompt—including history and RAG—into one string before scanning. Scanning just the latest message leaves prior-turn PII in the model context.

Related reading