Engineering
Updated
10 min read

Running PII detection locally with the Rampart NER model

How Arcjet added an optional on-device model backend for sensitive information detection: a pluggable rule interface, deterministic recognizers for structured data, offset reconstruction for token-classification output, and local ONNX inference in the request path.

Running PII detection locally with the Rampart NER model

Arcjet's sensitive information detection feature has always had a simple property: the analysis runs locally in your environment (your laptop or wherever you deploy your code). If you pass request text into our sensitiveInfo rule the Arcjet SDK can detect whether the request contains email addresses, phone numbers, IP addresses, or credit card numbers without sending that body to Arcjet.

The whole point of the feature is to help developers avoid handling sensitive data they do not want to store, log, process, or send onward to an AI model or elsewhere. Routing the body to cloud service just to discover that it contains PII would be the wrong tradeoff for many applications.

The limitation was the entity types we could detect. Pattern matching is a good fit for structured identifiers like email addresses and card numbers, but it is not enough for names and addresses:

My name is Alex Rivera and I live at 123 Main Street.

There is no checksum for "Alex", no delimiter that proves "Rivera" is a surname, and no universal regex for a street name - those are language and context problems, not just string-shape problems.

This is a problem for machine learning! And luckily, the US National Design Studio just released a new open source model that covers this exact use case. It’s optimized for running in the browser for AI chat interfaces, but since it’s only 14MB, we thought we’d see if we could get it running on the server as a new backend for the Arcjet sensitive info detection feature.

Keep the rule stable, make detection pluggable

The last few years has been focused on the big models form the major AI labs, Anthropic and OpenAI. However, I expect the next few years will see a proliferation of open source models. Although Rampart is state of the art today, it’s likely that newer models will replace it. This is great from an innovation perspective, but not so good if you need predictable results from your security rules - you don't necessarily want new models dropping into package updates.

Although we wanted to test Rampart, we wanted to build it in a way that is pluggable. That way you can keep using Arcjet’s existing deterministic detections if the entity types match your requirements, but if you need something more flexible then you can drop in Rampart. Then if a new model shows up in 6 months you can choose to keep the current model or swap it out for the new one - and run your evals to ensure it hasn’t regressed.

So our first implementation decision was to avoid turning sensitiveInfo into a Rampart-specific rule.

Existing applications already configure it like this:

sensitiveInfo({
  mode: "LIVE",
  deny: ["EMAIL", "CREDIT_CARD_NUMBER"],
});

That continues to use the default WebAssembly backend from @arcjet/analyze. The new path is opt-in:

import arcjet, { sensitiveInfo } from "@arcjet/node";
import { rampart } from "@arcjet/sensitive-info-rampart";

const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    sensitiveInfo({
      mode: "LIVE",
      deny: ["EMAIL", "GIVEN_NAME", "SURNAME", "STREET_NAME", "SSN"],
      backend: rampart(),
    }),
  ],
});

Internally, sensitiveInfo now accepts a small SensitiveInfoBackend interface:

interface SensitiveInfoBackend {
  detect(
    context: SensitiveInfoBackendContext,
    value: string,
    entities: SensitiveInfoEntities,
    options?: SensitiveInfoBackendOptions,
  ): Promise<SensitiveInfoResult>;
}

The default backend is just a wrapper around the existing WebAssembly detector. If no backend is configured, behavior is unchanged:

const wasmSensitiveInfoBackend: SensitiveInfoBackend = {
  detect(context, value, entities, options) {
    return analyze.detectSensitiveInfo(
      { log: context.log, characteristics: [] },
      value,
      entities,
      options?.contextWindowSize ?? 1,
      options?.detect,
    );
  },
};

That makes the model backend an implementation detail of detection, not a new policy primitive. The rest of the rule still decides whether the request is allowed or denied, still reports allowed and denied spans, still honors LIVE and DRY_RUN, and still returns an ArcjetSensitiveInfoReason.

This also keeps the API additive. ArcjetSensitiveInfoType is widened to include the new entity names, but listing one of those names without a backend that can emit it has no effect. The default backend does not suddenly claim it can detect surnames.

Model output is not policy output

Rampart is a token-classification model. Arcjet policy needs character spans.

The model emits tokens with labels like B-GIVEN_NAME and I-STREET_NAME. In the @huggingface/transformers path we use, the token-classification pipeline gives Arcjet the token text, score, label, and token order, but not original character offsets.

A policy result needs offsets into the original request text:

{
  "start": 11,
  "end": 15,
  "identifiedType": "GIVEN_NAME"
}

That offset has to refer to the user's actual string, not the model's normalized token text. Our implementation reconstructs those offsets in two steps.

First, it normalizes the input the same way the tokenizer does for this model: lower-case, strip accents using Unicode NFD decomposition, and keep a map from every normalized character back to the original string index.

export function normalizeWithMap(value: string) {
  const chars: string[] = [];
  const map: number[] = [];
  for (let index = 0; index < value.length; index++) {
    const normalized = value[index].normalize("NFD").toLowerCase();
    for (const char of normalized) {
      if (combiningMark.test(char)) continue;
      chars.push(char);
      map.push(index);
    }
  }
  return { normalized: chars.join(""), map };
}

There is a fast path for ASCII because request text is usually ASCII-heavy and this code runs in the request path.

Second, it walks the model tokens in sequence, strips BERT-style ## subword prefixes, finds each token in the normalized input using a forward-moving cursor, and maps the normalized start/end back to original offsets.

This is intentionally simple. It does not try to infer missing tokens from context. If a token cannot be located, it is left without offsets and dropped later. For a security decision, a span with incorrect offsets is worse than no span.

Once offsets are assigned, adjacent tokens of the same entity type are aggregated into one span when the text between them is whitespace:

Main     Street
^^^^     ^^^^^^
B-STREET I-STREET

becomes:

{
  "start": 0,
  "end": 15,
  "type": "STREET_NAME"
}

The aggregation step also drops tokens below the configured confidence threshold, O labels, unknown labels, and tokens with missing offsets.

Structured data still belongs to recognizers

Using an NER model does not mean every entity should come from the model. Some sensitive data has structure we can validate more reliably than a model can infer:

  • email addresses
  • URLs
  • IP addresses
  • phone numbers
  • US Social Security numbers
  • credit card numbers

The Rampart backend runs deterministic recognizers for those types alongside the model. The card recognizer also validates candidates with the Luhn checksum, so a random 16-digit-looking string is not treated the same as a valid card number. Our original implementation supported all of these except US SSNs, because our original testing showed they were too close to phone numbers to detect the difference accurately.

This gives the backend two sources of spans:

recognizers -> structured, validated matches
model       -> contextual named entities

Then the backend merges them into one non-overlapping list.

Overlap handling is where a lot of bugs hide. If the model labels a card number as a bank account but the recognizer has a Luhn-valid credit card match over the same characters, the recognizer should win. If a loose phone-number recognizer happens to match the leading 1 in 1 Infinite Loop, it should not delete the longer street-name span found by the model.

The merge algorithm ranks spans by length first, then by source priority, then by start offset:

ranked.sort((a, b) => {
  const lengthA = a.span.end - a.span.start;
  const lengthB = b.span.end - b.span.start;
  if (lengthA !== lengthB) return lengthB - lengthA;
  if (a.priority !== b.priority) return a.priority - b.priority;
  return a.span.start - b.span.start;
});

Longest span wins. For equal-length overlaps, recognizers win over the model because they are deterministic and validated. Accepted spans are sorted back into document order before policy is applied.

That gives us the behavior we want:

  • a valid card beats a model guess over the same text
  • a longer address span beats a tiny accidental numeric match
  • duplicate spans from chunk overlap collapse naturally
  • the final result still looks like the old sensitiveInfo result

Allow and deny stay backend-independent

Arcjet's rule supports two modes of entity policy:

sensitiveInfo({ deny: ["EMAIL", "SSN"] });
sensitiveInfo({ allow: ["EMAIL"] });

In deny mode, listed types are denied and everything else is allowed. In allow mode, listed types are allowed and everything else detected is denied.

The backend receives the configured entities in the same internal tagged-union shape used by our @arcjet/analyze WebAssembly analyzer. The new Rampart backend package maps between that internal shape and public Arcjet entity names:

if (entity.tag === "email") return "EMAIL";
if (entity.tag === "phone-number") return "PHONE_NUMBER";
if (entity.tag === "ip-address") return "IP_ADDRESS";
if (entity.tag === "credit-card-number") return "CREDIT_CARD_NUMBER";
return entity.val as ArcjetSensitiveInfoType;

The four original WebAssembly types keep their native tags - everything else is carried as a custom value internally, then converted back to the plain public string before user code sees it.

That mapping layer lets the default backend, the new model backend, and the existing reason/result shape agree on one policy vocabulary.

Load the model once, but do not cache failure

Model loading is expensive. The backend lazily loads the transformers.js token-classification pipeline on first use using bundled quantized ONNX weights, then reuses it for later requests. The cache key includes the model path, model id, dtype, and device, so different backend configurations do not accidentally share a classifier:

const key = JSON.stringify({ modelPath, modelId, dtype, device });
const existing = classifierCache.get(key);
if (existing !== undefined) {
  return existing;
}

The cache stores a promise, not just a loaded classifier. That means concurrent requests arriving during cold start all wait on the same load instead of trying to initialize the same model several times.

There is one important edge case: failed loads are not cached.

If the ONNX runtime cannot load, or the model path is wrong, the error should propagate to the caller. But a later call should be able to retry. Caching the rejected promise would poison the process until restart. The implementation deletes the cache entry on failure:

try {
  return await promise;
} catch (error) {
  classifierCache.delete(key);
  throw error;
}

There is also a subtle transformers.js detail: its env configuration is process-global. The backend needs to set allowRemoteModels = false and point localModelPath at the bundled model directory, but it should not permanently mutate global state for other code in the same process.

The loader serializes model loads through a promise chain, saves the previous env values, sets Arcjet's local-only configuration for the duration of the load, then restores the previous values in finally. That ensures the model is bundled, local, and never fetched at runtime, without assuming Arcjet is the only transformers.js user in the process. The last thing you want when starting a server (or serverless function) is sizable remote content being loaded (14MB is fine to bundle locally, but we don't want to load it from a remote on every request).

Long input has to be chunked

One other problem we needed to solve was that the model only has a 512-token window, but request bodies can be longer than that.

Letting the ONNX runtime throw for long inputs would make the rule fragile when processing large strings (or provide an easy way to bypass it for attackers). The backend handles long values by scanning overlapping character windows:

const MAX_INPUT_CHARS = 480;
const CHUNK_OVERLAP = 64;
const step = MAX_INPUT_CHARS - CHUNK_OVERLAP;
for (let start = 0; start < value.length; start += step) {
  const chunk = value.slice(start, start + MAX_INPUT_CHARS);
  const tokens = await classifier(chunk);
  // aggregate spans, then rebase offsets by `start`
}

This is an engineering compromise rather than a general tokenizer scheduler. Each wordpiece token consumes at least one character, so a 480-character window stays below the model's 512-token limit. The 64-character overlap gives entities near a boundary enough room to be detected in at least one chunk.

Because the merge step already resolves overlaps, duplicate detections from neighboring chunks do not require a special path.

Framework integration is about not bundling the model

The new package works in server runtimes with filesystem and native-addon access: Node.js, Bun, and Deno. The Edge runtime is deprecated, so we didn’t add support for it. However, we have to provide specific guidance for Next.js

The package loads @huggingface/transformers, onnxruntime-node, and bundled model files from disk at runtime. If a server build tries to bundle those dependencies, native binaries and model files can fail to resolve.

The solution is to mark them as server external packages:

const nextConfig = {
  serverExternalPackages: [
    "@arcjet/sensitive-info-rampart",
    "@huggingface/transformers",
    "onnxruntime-node",
  ],
};
module.exports = nextConfig;

Lots of tests

We have a comprehensive test suite because there are a variety of invariants that must be kept.

  • label normalization and entity conversion
  • recognizers for email, URL, IP, SSN, phone, and Luhn-valid card numbers
  • Unicode normalization and offset mapping
  • subword-token offset assignment
  • token aggregation across whitespace
  • allow/deny semantics
  • overlap resolution between recognizers and model spans

There are integration tests that run the real model when the local runtime can load it, but skip cleanly when the native ONNX binding is unavailable. Those tests check that the backend can detect names, street names, SSNs, and emails with correct offsets.

There is also a regression test for input longer than the model window. Before chunking, that path could fail in ONNX runtime. The test puts a name beyond the 512-token boundary and verifies it is still found with correct offsets.

Finally, we have performance tests to monitor the first model call cold start time, warm reuse, and the performance of the static recognizers.

Conclusions

The default Arcjet sensitive-info detector is still a good backend for the common lightweight path: small, local WebAssembly, good coverage for structured data, and no model runtime. If you need detections that execute deterministically within a millisecond then it's still a good choice.

Rampart is a new, optional path for applications that need broader PII detection locally. It brings a bundled ONNX model into the request path, so it has real deployment constraints: server runtime, native ONNX support, model load latency, and larger package size. The tradeoff is that it can detect entities regexes cannot reasonably express, while keeping request text inside the application environment. Despite this, p50 performance of ~6.5m is still excellent.

This new functionality will be shipping in the upcoming set of Arcjet SDK releases next month. In the meantime, you can explore the implementation in our open source SDK repo.

Related articles

Designing a CLI for AI agents
Engineering
9 min read

Designing a CLI for AI agents

How we designed the Arcjet CLI in Go as a stable, defensive interface for humans and AI agents: predictable commands, machine-readable output, strict validation, and confirmation before production changes.

Subscribe by email

Get the full posts by email every week.