WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Data Anonymisation Layer: Protecting PII Before Sending Data to External AI APIs
Advanced WordPress Topic
October 31, 2026

Data Anonymisation Layer: Protecting PII Before Sending Data to External AI APIs

Data Anonymisation Layer: Protecting PII Before Sending Data to External AI APIs

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.php or 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.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Backfill Post Embeddings at Scale: A Resumable WP-CLI Command with OpenAI Batches and Cost Guardrails

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Backfill Post Embeddings at Scale: A Resumable WP-CLI Command with OpenAI Batches and Cost Guardrails Backfill Post Embeddings at Scale: A Resumable WP-CLI Command with OpenAI Batches and Cost Guardrails
  • Audit AI Plugin REST Routes: A WP-CLI Command That Flags Missing permission_callback Audit AI Plugin REST Routes: A WP-CLI Command That Flags Missing permission_callback

Leave a Reply

Cancel reply

WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed