Why masking must happen before the request leaves your server
Every prompt you forward to OpenAI, Anthropic, Mistral or Google is a data transfer to a third-party sub-processor. Under UK GDPR and the EU GDPR, Article 5(1)(c) requires data minimisation: you send only what the task actually needs. A support chatbot does not need the customer’s real email address to draft a reply — it needs a placeholder that survives the round trip.
Most tutorials solve this by telling you to “strip PII” and then hand you a regex. That is not a layer. A layer has four properties: it is reversible (the user still sees their own data in the answer), it is auditable (you can prove what left the building), it is extensible (you can add detectors without editing core code), and it is fail-closed (if detection throws, the request does not go out).
This tutorial builds that layer as a small plugin that wraps wp_remote_post(). Nothing here depends on a specific AI vendor, and it works alongside the official AI Services plugin if you are already using it.
Prerequisites
- WordPress 6.4 or later with PHP 8.1+ (the code uses typed properties and union return types).
- A plugin folder you control — this example uses
wp-content/plugins/wpth-ai-privacy/. - An API key stored outside the database, ideally in
wp-config.phpor an environment variable. - WP-CLI for the round-trip test at the end.
- A basic grasp of wp_remote_post() and apply_filters().
Important anonymisation reduces risk, it does not remove your obligation to sign a Data Processing Agreement with the AI vendor or to record the transfer in your privacy notice. Treat this as one control in a set, not a compliance certificate.
The pipeline in one picture
Detect → tokenise → transmit → restore → sanitise → render. The token vault lives only for the duration of a single function call. It is never written to wp_options, never cached, and never logged. That single design decision is what makes the scheme safe: there is no stored mapping that an attacker could join back to the original text.
Step 1 Create a per-request token vault
The vault replaces each match with a stable, human-readable token such as [PII_EMAIL_1]. Readable tokens matter because language models preserve them far more reliably than random UUIDs — the model can see that it is a placeholder, not prose.
<?php
/**
* Plugin Name: WPTH AI Privacy Layer
* Description: Tokenises PII before outbound AI requests and restores it on return.
* Version: 1.0.0
*/
final class WPTH_Token_Vault {
private array $forward = []; // "type:value" => token
private array $reverse = []; // token => original value
private int $counter = 0;
public function __construct( private string $prefix = 'PII' ) {}
public function tokenise( string $value, string $type ): string {
$key = $type . ':' . $value;
if ( isset( $this->forward[ $key ] ) ) {
return $this->forward[ $key ]; // same value, same token
}
$this->counter++;
$token = sprintf( '[%s_%s_%d]', $this->prefix, strtoupper( $type ), $this->counter );
$this->forward[ $key ] = $token;
$this->reverse[ $token ] = $value;
return $token;
}
public function restore( string $text ): string {
return $this->reverse ? strtr( $text, $this->reverse ) : $text;
}
public function counts(): array {
$counts = [];
foreach ( array_keys( $this->forward ) as $key ) {
[ $type ] = explode( ':', $key, 2 );
$counts[ $type ] = ( $counts[ $type ] ?? 0 ) + 1;
}
return $counts;
}
}
Deterministic tokenisation is deliberate. If the same email appears three times in a prompt, all three become [PII_EMAIL_1], so the model keeps the referential meaning of the text intact.
Step 2 Define detectors you can filter
Hard-coding patterns into the anonymiser makes the layer brittle. Expose them through a filter so a theme or a second plugin can add detectors for staff names, order numbers or internal project codes without forking your file.
<?php
function wpth_pii_detectors(): array {
$detectors = [
'email' => '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i',
'phone' => '/(?:\+44\s?7\d{3}|\+\d{1,3}[\s\-]?\d{6,}|0\d{3,4}[\s\-]?\d{3}[\s\-]?\d{3,4})/',
'postcode' => '/\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/i',
'nino' => '/\b(?!BG|GB|NK|KN|TN|NT|ZZ)[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]\b/i',
'iban' => '/\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/',
'ipv4' => '/\b(?:\d{1,3}\.){3}\d{1,3}\b/',
];
/**
* Filter the PII detector map.
*
* @param array<string, string> $detectors Type => PCRE pattern.
*/
return (array) apply_filters( 'wpth_pii_detectors', $detectors );
}
Order matters. Put the most specific patterns first so a National Insurance number is not half-eaten by the phone pattern. Card numbers deserve their own detector with a Luhn check, because a bare \d{16} regex will happily redact order IDs and timestamps.
Step 3 Anonymise the prompt
The anonymiser is intentionally tiny. It walks the detector map and swaps each match for a vault token. Note the null guard — preg_replace_callback() returns null when PCRE hits its backtrack limit, and silently passing null downstream would send an empty prompt.
<?php
function wpth_anonymise( string $text, WPTH_Token_Vault $vault ): string {
foreach ( wpth_pii_detectors() as $type => $pattern ) {
$result = preg_replace_callback(
$pattern,
static fn( array $m ): string => $vault->tokenise( $m[0], $type ),
$text
);
if ( null === $result ) {
// Fail closed: never forward a prompt we could not inspect.
throw new RuntimeException( 'PII detector failed for type: ' . $type );
}
$text = $result;
}
return $text;
}
If you prefer redaction over pseudonymisation — for example when the answer never needs the real value — swap the callback for static fn(): string => '[REDACTED]'. You lose reversibility but gain the strongest guarantee.
Step 4 Wrap the outbound call
This is the middleware itself. Everything the rest of your codebase calls goes through wpth_ai_complete(), never through a raw HTTP request. The system message is not decoration: telling the model to preserve bracketed tokens verbatim measurably reduces mangling.
<?php
function wpth_ai_complete( string $prompt, array $args = [] ): string|WP_Error {
$vault = new WPTH_Token_Vault();
try {
$safe = wpth_anonymise( $prompt, $vault );
} catch ( RuntimeException $e ) {
return new WP_Error( 'wpth_pii_failed', $e->getMessage() );
}
$body = [
'model' => $args['model'] ?? 'gpt-4o-mini',
'temperature' => 0.2,
'messages' => [
[
'role' => 'system',
'content' => 'Tokens of the form [PII_TYPE_N] are placeholders for real data. '
. 'Reproduce them exactly as written. Never invent or expand them.',
],
[ 'role' => 'user', 'content' => $safe ],
],
];
$response = wp_remote_post(
'https://api.openai.com/v1/chat/completions',
[
'timeout' => 30,
'data_format' => 'body',
'headers' => [
'Authorization' => 'Bearer ' . WPTH_OPENAI_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$json = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || empty( $json['choices'][0]['message']['content'] ) ) {
return new WP_Error( 'wpth_ai_http', 'Upstream AI request failed.', [ 'status' => $code ] );
}
wpth_ai_audit( $vault->counts(), $code, strlen( $safe ) );
return $vault->restore( $json['choices'][0]['message']['content'] );
}
Define the key in wp-config.php with define( 'WPTH_OPENAI_KEY', getenv( 'OPENAI_API_KEY' ) ); so it never lands in the options table or a database export.
Step 5 Restore, then sanitise, then render
restore() puts the real values back into the model’s reply. That reply is still untrusted text — a prompt injection could have persuaded the model to emit markup. Escape at the point of output, not inside the wrapper, so the same string can be used in a JSON API response or an email:
<?php
// Inside a REST callback or template.
$answer = wpth_ai_complete( $user_message );
if ( is_wp_error( $answer ) ) {
wp_send_json_error( [ 'message' => 'Please try again.' ], 502 );
}
echo wp_kses_post( wpautop( $answer ) ); // HTML context
echo esc_html( $answer ); // plain-text context
Never echo the raw string, and never hand it to innerHTML in your chat widget. Use textContent or a sanitised HTML fragment.
Names and addresses: the hard part
Regex cannot reliably find a person’s name. Two pragmatic approaches work in production. First, a known-names dictionary built from your user table or a staff list. Second, a title-case heuristic that flags two consecutive capitalised words — noisy, so gate it behind a filter and a per-site toggle.
<?php
add_filter( 'wpth_pii_detectors', function ( array $detectors ): array {
$names = (array) get_option( 'wpth_known_names', [] );
if ( $names ) {
$quoted = array_map( 'preg_quote', $names );
$detectors['name'] = '/\b(' . implode( '|', $quoted ) . ')\b/iu';
}
return $detectors;
} );
For postal addresses, the postcode detector catches most UK cases. For full street addresses, add a detector for a house number followed by a capitalised word and a street suffix (Road|Street|Lane|Avenue|Close|Way). Accept that you will miss some — which is why the audit log below exists.
Keep an audit trail without keeping the data
You need to prove what left your server. You do not need to store the prompt. Log counts, a hashed user reference, and timing. That is enough for an ICO enquiry and useless to an attacker.
{
"event": "ai_request",
"request_id": "01J8Z4K3Q9XW",
"user_hash": "sha256:9f2c41ab...",
"model": "gpt-4o-mini",
"tokens_found": { "email": 1, "phone": 1, "postcode": 1 },
"chars_sent": 71,
"upstream_status": 200,
"duration_ms": 812
}
Write it with error_log() to a dedicated file, or into a custom table with a 30-day retention job. Never include $safe or the restored answer.
Test the round trip
A layer you have not tested is a layer that silently leaks. Run this with WP-CLI before wiring the wrapper into anything user-facing:
<?php
// wp eval-file tests/test-anonymise.php
$vault = new WPTH_Token_Vault();
$input = 'Hi, I am Sarah Okafor, [email protected], 07700 900123, SW1A 1AA.';
$safe = wpth_anonymise( $input, $vault );
WP_CLI::log( $safe );
// Hi, I am Sarah Okafor, [PII_EMAIL_1], [PII_PHONE_2], [PII_POSTCODE_3].
WP_CLI::log( $vault->restore( $safe ) === $input ? 'round-trip OK' : 'round-trip FAILED' );
WP_CLI::log( 'Detected: ' . wp_json_encode( $vault->counts() ) );
Add a second test with a deliberately malformed string (an unterminated email, a 40-digit number) to confirm the fail-closed path returns a WP_Error rather than sending anything.
Troubleshooting
The model renames or drops tokens
Shorten the token ([E1] instead of [PII_EMAIL_1]) and repeat the preservation instruction in the user message as well as the system message. As a safety net, run restore() and then scan the output for any leftover [PII_ substring; if found, log it and return a generic reply rather than showing broken placeholders.
Over-masking destroys meaning
The phone pattern is the usual culprit — it will match order references and dates. Anchor it with a word boundary and require at least nine digits. Test against twenty real prompts from your own logs before shipping.
Requests hang or time out
A pathological regex on a long prompt can blow PCRE’s backtrack limit. Set pcre.backtrack_limit explicitly, cap prompt length at, say, 8,000 characters, and check preg_last_error() after each replacement.
Streaming responses break restoration
Server-sent events arrive in fragments, so a token can be split across two chunks. Either buffer the full response before restoring, or use a streaming-aware restorer that holds back any partial [PII_ prefix until the closing bracket arrives.
Non-English names slip through
Latin-only title-case heuristics fail on many scripts. Add the u flag to every pattern, and prefer the dictionary approach for any locale where you have a reliable name list.
Next steps
- Move the vault behind an interface so you can swap in a format-preserving scheme that keeps postcodes looking like postcodes.
- Add a per-user salt to the audit hash so you can correlate requests without building a global identifier.
- Wire the wrapper into the WordPress privacy tools so erasure requests also purge your audit rows.
- Add a settings screen with a per-detector toggle, and default every new detector to on.
- Read the sanitising and escaping guide again before you render any model output — the restore step is where untrusted text re-enters your templates.
Leave a Reply