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/AI Content Provenance for WordPress: Log Model, Prompt Hash and Token Usage per Post with a Review Diff
Advanced WordPress Topic
November 8, 2026

AI Content Provenance for WordPress: Log Model, Prompt Hash and Token Usage per Post with a Review Diff

AI Content Provenance for WordPress: Log Model, Prompt Hash and Token Usage per Post with a Review Diff

A disclaimer under the article is not provenance. Provenance is a record you can query six months later: which model produced this paragraph, which prompt produced that model output, how many tokens it cost, and which human signed it off. If your editorial policy, a client contract or a regulator ever asks “was this written by a machine, and who checked it?”, a footnote will not save you — a meta row will.

This tutorial stores a structured provenance record on every AI-assisted post, hashes the prompt instead of storing it, and adds a reviewer-facing diff that compares the human baseline against the current content. The diff is gated by edit_post and a nonce, and it is rendered server-side with WordPress’s own wp_text_diff() — no hand-rolled HTML injection.

Why a hash and not the prompt itself

Prompts leak. They contain client names, unpublished product details, internal URLs and occasionally a pasted paragraph of someone else’s copyrighted text. Storing the raw prompt in post meta means it lands in every database backup, every staging clone and every support export.

A keyed hash gives you the useful part — was this the same prompt we used on the other twelve posts? — without the liability. Use hash_hmac() with wp_salt() rather than a bare hash(), so an attacker who guesses your prompt cannot confirm it by hashing the guess themselves. The trade-off is real: rotate the salt and every stored hash becomes unverifiable. Document that in your runbook.

Prerequisites

  • WordPress 6.5 or later (block bindings, wp_date(), typed meta).
  • PHP 8.0+ with the hash extension (bundled by default).
  • A child theme or small site plugin — this code belongs in a plugin, not functions.php, because you want it active when the theme is switched.
  • Your AI generation pipeline must be able to call a PHP function after it writes content. If generation happens outside WordPress, expose a signed REST route or a WP-CLI command that calls the same recorder.
  • WP-CLI access for the backfill step.

Step 1 Register two meta keys

You need two records, not one. _wpth_ai_provenance holds the current run’s metadata. _wpth_ai_baseline holds a snapshot of the content before the first AI run, which is what the diff compares against. The leading underscore marks both as protected meta, so they never appear in the Custom Fields panel where an editor could break them.

add_action( 'init', 'wpth_register_provenance_meta' );

function wpth_register_provenance_meta() {
    $guard = static function ( $allowed, $meta_key, $post_id ) {
        return current_user_can( 'edit_post', $post_id );
    };

    register_post_meta(
        'post',
        '_wpth_ai_provenance',
        array(
            'type'              => 'object',
            'single'            => true,
            'show_in_rest'      => false,
            'sanitize_callback' => 'wpth_sanitize_provenance',
            'auth_callback'     => $guard,
        )
    );

    register_post_meta(
        'post',
        '_wpth_ai_baseline',
        array(
            'type'          => 'string',
            'single'        => true,
            'show_in_rest'  => false,
            'auth_callback' => $guard,
        )
    );
}

Why show_in_rest is false: the record is for auditors, not for the block editor or a headless front end. Keeping it out of REST removes an entire class of exposure. If you later need it over REST, register a dedicated read-only route with its own capability check rather than flipping this flag.

Why auth_callback matters: it is not decoration. WordPress maps it into the edit_post_meta capability check, so even a direct update_post_meta() call from a rogue plugin is filtered through edit_post on that specific post.

The sanitiser

Never trust the array shape coming out of your pipeline. Cast every field, reject anything that is not a 64-character lowercase hex hash, and drop unknown keys entirely.

function wpth_sanitize_provenance( $value ) {
    if ( ! is_array( $value ) ) {
        return array();
    }

    $clean = array(
        'provider'     => sanitize_key( $value['provider'] ?? '' ),
        'model'        => sanitize_text_field( $value['model'] ?? '' ),
        'prompt_hash'  => '',
        'prompt_chars' => absint( $value['prompt_chars'] ?? 0 ),
        'tokens_in'    => absint( $value['tokens_in'] ?? 0 ),
        'tokens_out'   => absint( $value['tokens_out'] ?? 0 ),
        'generated_at' => absint( $value['generated_at'] ?? 0 ),
        'editor_id'    => absint( $value['editor_id'] ?? 0 ),
        'reviewed_by'  => absint( $value['reviewed_by'] ?? 0 ),
        'run_id'       => sanitize_text_field( $value['run_id'] ?? '' ),
    );

    $hash = strtolower( (string) ( $value['prompt_hash'] ?? '' ) );

    if ( preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
        $clean['prompt_hash'] = $hash;
    }

    return $clean;
}

Step 2 Hash the prompt deterministically

Normalise whitespace before hashing. Otherwise a prompt that differs only by a trailing newline produces a different hash and your “have we run this before?” query returns false negatives forever.

function wpth_hash_prompt( string $prompt, string $provider, string $model ): string {
    $normalised = trim( preg_replace( '/\s+/u', ' ', $prompt ) );
    $material   = $provider . '|' . $model . '|' . $normalised;

    return hash_hmac( 'sha256', $material, wp_salt( 'auth' ) );
}

Including the provider and model in the material means the same prompt sent to two different models yields two different hashes — which is what you want, because the output will differ too.

Step 3 Write the record from the pipeline

Call this from the code that actually writes the generated content. Do not hook it to save_post: that fires on autosaves, revisions, bulk edits and REST saves, and you will end up with provenance records for posts no model ever touched.

function wpth_record_ai_provenance( int $post_id, array $run ): bool {
    $post = get_post( $post_id );

    if ( ! $post || 'post' !== $post->post_type ) {
        return false;
    }

    $existing = get_post_meta( $post_id, '_wpth_ai_provenance', true );

    // Snapshot the human-written content once, before the first AI run.
    if ( empty( $existing ) ) {
        update_post_meta( $post_id, '_wpth_ai_baseline', wp_slash( $post->post_content ) );
        update_post_meta( $post_id, '_wpth_ai_baseline_at', time() );
    }

    $record = wpth_sanitize_provenance(
        array(
            'provider'     => $run['provider'],
            'model'        => $run['model'],
            'prompt_hash'  => wpth_hash_prompt( $run['prompt'], $run['provider'], $run['model'] ),
            'prompt_chars' => mb_strlen( $run['prompt'] ),
            'tokens_in'    => $run['tokens_in'],
            'tokens_out'   => $run['tokens_out'],
            'generated_at' => time(),
            'editor_id'    => get_current_user_id(),
            'run_id'       => $run['run_id'] ?? wp_generate_uuid4(),
        )
    );

    update_post_meta( $post_id, '_wpth_ai_provenance', $record );

    return true;
}

wp_slash() on the baseline is not optional. update_post_meta() expects slashed data, and post content routinely contains backslashes and quotes that will be silently mangled without it.

Step 4 Build the review metabox

The metabox shows the metadata and a button that submits to admin-post.php. It does not render the diff itself — that happens in a POST handler where the nonce can actually be verified.

add_action( 'add_meta_boxes_post', function () {
    add_meta_box(
        'wpth-ai-provenance',
        __( 'AI provenance', 'wpth' ),
        'wpth_render_provenance_box',
        'post',
        'side',
        'high'
    );
} );

function wpth_render_provenance_box( WP_Post $post ) {
    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
        return;
    }

    $record = get_post_meta( $post->ID, '_wpth_ai_provenance', true );

    if ( empty( $record['model'] ) ) {
        echo '<p>' . esc_html__( 'No AI generation recorded for this post.', 'wpth' ) . '</p>';
        return;
    }

    $rows = array(
        __( 'Provider', 'wpth' )    => $record['provider'],
        __( 'Model', 'wpth' )       => $record['model'],
        __( 'Generated', 'wpth' )   => wp_date( 'Y-m-d H:i', $record['generated_at'] ),
        __( 'Prompt hash', 'wpth' ) => substr( $record['prompt_hash'], 0, 16 ) . '…',
        __( 'Tokens', 'wpth' )      => sprintf(
            '%s in / %s out',
            number_format_i18n( $record['tokens_in'] ),
            number_format_i18n( $record['tokens_out'] )
        ),
        __( 'Run by', 'wpth' )      => get_the_author_meta( 'display_name', $record['editor_id'] ),
    );

    echo '<table class="widefat striped"><tbody>';

    foreach ( $rows as $label => $value ) {
        printf(
            '<tr><th scope="row">%s</th><td>%s</td></tr>',
            esc_html( $label ),
            esc_html( (string) $value )
        );
    }

    echo '</tbody></table>';

    printf(
        '<form method="post" action="%s" style="margin-top:1em;">',
        esc_url( admin_url( 'admin-post.php' ) )
    );
    wp_nonce_field( 'wpth_provenance_diff_' . $post->ID, 'wpth_provenance_nonce' );
    echo '<input type="hidden" name="action" value="wpth_provenance_diff">';
    printf( '<input type="hidden" name="post_id" value="%d">', $post->ID );
    submit_button( __( 'Review diff', 'wpth' ), 'secondary', 'submit', false );
    echo '</form>';
}

Step 5 Serve the diff through a nonce-checked handler

This is the part most tutorials get wrong. They fetch the baseline over REST and inject it with innerHTML, which turns a review tool into a stored XSS vector. Use wp_text_diff() instead: it escapes every line with esc_html() internally and returns a complete table.

add_action( 'admin_post_wpth_provenance_diff', 'wpth_handle_provenance_diff' );

function wpth_handle_provenance_diff() {
    $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;

    if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
        wp_die(
            esc_html__( 'You are not allowed to review this post.', 'wpth' ),
            '',
            array( 'response' => 403 )
        );
    }

    check_admin_referer( 'wpth_provenance_diff_' . $post_id, 'wpth_provenance_nonce' );

    $baseline = (string) get_post_meta( $post_id, '_wpth_ai_baseline', true );
    $current  = (string) get_post_field( 'post_content', $post_id );
    $record   = (array) get_post_meta( $post_id, '_wpth_ai_provenance', true );

    require_once ABSPATH . 'wp-admin/includes/diff.php';

    $diff = wp_text_diff(
        $baseline,
        $current,
        array(
            'title_left'      => __( 'Before AI', 'wpth' ),
            'title_right'     => __( 'Current', 'wpth' ),
            'show_split_view' => false,
        )
    );

    if ( '' === $diff ) {
        $diff = '<p>' . esc_html__( 'No textual change since the baseline was captured.', 'wpth' ) . '</p>';
    }

    wpth_render_diff_page( $post_id, $record, $diff );
}

Two details worth knowing. First, wp_text_diff() returns an empty string — not null — when the two sides match, so test with '' === $diff. Second, it runs both sides through normalize_whitespace() before splitting on newlines, which means block comment delimiters like <!-- wp:paragraph --> appear as diff lines. That is a feature: a reviewer can see when a block type changed, not just the words.

function wpth_render_diff_page( int $post_id, array $record, string $diff ) {
    echo '<!DOCTYPE html><html ' . get_language_attributes() . '><head>';
    echo '<meta charset="' . esc_attr( get_bloginfo( 'charset' ) ) . '">';
    echo '<title>' . esc_html__( 'AI review diff', 'wpth' ) . '</title>';
    wp_admin_css( 'wp-admin' );
    echo '</head><body class="wp-core-ui"><div class="wrap">';

    printf(
        '<h1>%s</h1>',
        esc_html( sprintf( __( 'AI review diff: %s', 'wpth' ), get_the_title( $post_id ) ) )
    );

    printf(
        '<p>%s</p>',
        esc_html(
            sprintf(
                /* translators: 1: model, 2: provider, 3: date */
                __( 'Model %1$s from %2$s, generated %3$s.', 'wpth' ),
                $record['model'],
                $record['provider'],
                wp_date( 'Y-m-d H:i', (int) $record['generated_at'] )
            )
        )
    );

    // $diff comes from wp_text_diff(): already escaped and wrapped in a table.
    echo $diff;

    printf(
        '<p><a href="%s">%s</a></p>',
        esc_url( get_edit_post_link( $post_id ) ),
        esc_html__( 'Back to the editor', 'wpth' )
    );

    echo '</div></body></html>';
    exit;
}

Step 6 Surface token spend in the posts list

Provenance you never look at is dead weight. A column in the posts list makes model and token cost visible at a glance, which is usually enough to spot a runaway prompt before the invoice arrives.

add_filter( 'manage_post_posts_columns', function ( $columns ) {
    $columns['wpth_ai'] = __( 'AI', 'wpth' );
    return $columns;
} );

add_action( 'manage_post_posts_custom_column', function ( $column, $post_id ) {
    if ( 'wpth_ai' !== $column ) {
        return;
    }

    $record = get_post_meta( $post_id, '_wpth_ai_provenance', true );

    if ( empty( $record['model'] ) ) {
        echo '&mdash;';
        return;
    }

    $total = (int) $record['tokens_in'] + (int) $record['tokens_out'];

    printf(
        '<span title="%s">%s &middot; %s</span>',
        esc_attr(
            sprintf(
                /* translators: 1: prompt hash, 2: input tokens, 3: output tokens */
                __( 'Prompt hash %1$s, %2$d in / %3$d out', 'wpth' ),
                $record['prompt_hash'],
                $record['tokens_in'],
                $record['tokens_out']
            )
        ),
        esc_html( $record['model'] ),
        esc_html( number_format_i18n( $total ) )
    );
}, 10, 2 );

Step 7 Publish a disclosure with block bindings

Readers deserve the same transparency your auditors get. Block bindings (WordPress 6.5+) let a paragraph pull its text from post meta, so the disclosure stays in sync without anyone editing the post again.

add_action( 'init', function () {
    register_block_bindings_source(
        'wpth/provenance',
        array(
            'label'              => __( 'AI provenance', 'wpth' ),
            'uses_context'       => array( 'postId' ),
            'get_value_callback' => static function ( array $args, WP_Block $block ) {
                $post_id = isset( $block->context['postId'] )
                    ? (int) $block->context['postId']
                    : (int) get_the_ID();

                $record = get_post_meta( $post_id, '_wpth_ai_provenance', true );

                if ( empty( $record['model'] ) ) {
                    return '';
                }

                return sprintf(
                    /* translators: 1: model name, 2: date */
                    __( 'AI-assisted draft produced with %1$s and reviewed on %2$s.', 'wpth' ),
                    $record['model'],
                    wp_date( get_option( 'date_format' ), (int) $record['generated_at'] )
                );
            },
        )
    );
} );

In the editor, add a Paragraph block, open the block toolbar’s binding panel, and pick AI provenance. The block renders the returned string and escapes it for you — do not return HTML from the callback.

Backfilling posts that predate the code

Any post already AI-assisted before you shipped this has no baseline, so its diff will be empty. Capture one from the current content and accept that the first diff is a no-op — from then on, changes are tracked.

wp eval '
$ids = get_posts( array(
    "post_type"      => "post",
    "post_status"    => "any",
    "posts_per_page" => -1,
    "fields"         => "ids",
    "meta_key"       => "_wpth_ai_provenance",
) );

foreach ( $ids as $id ) {
    if ( get_post_meta( $id, "_wpth_ai_baseline", true ) ) {
        continue;
    }

    update_post_meta( $id, "_wpth_ai_baseline", wp_slash( get_post_field( "post_content", $id ) ) );
    WP_CLI::log( "Baseline captured for post {$id}" );
}
'

Troubleshooting

  • The diff page returns “Are you sure you want to do this?” — the nonce expired. Nonces live 12–24 hours, so a tab left open overnight fails. Reload the editor and press the button again.
  • Metadata saves but the metabox shows nothing — your sanitiser is dropping the array. Log the return value of wpth_sanitize_provenance(); the usual culprit is a hash that is not exactly 64 lowercase hex characters, or a provider string that sanitize_key() reduces to an empty string.
  • Baseline contains escaped quotes — you skipped wp_slash() on write, or applied wp_unslash() twice on read. Read with get_post_meta() and do not unslash again.
  • Every hash changed after a migration — wp_salt() is derived from AUTH_KEY and friends. If those constants differ between environments, hashes are not comparable across them. Store the salt fingerprint alongside the record if cross-environment comparison matters.
  • Token totals look doubled — you are recording both the generation run and a follow-up rewrite into the same single meta key. Either keep a history array or accept that the record reflects the latest run only, and say so in your policy.
  • Diff shows the whole post as changed — the baseline was captured after the AI write, not before. Check the order of operations in wpth_record_ai_provenance().

Next steps

  • Add a reviewed_by and reviewed_at pair written from a second metabox button, so sign-off is a separate, timestamped event from generation.
  • Store the WordPress revision ID that the AI run produced, then link the diff screen to revision.php for a native side-by-side.
  • Query the meta with WP_Query and a meta_key to build a monthly token-spend report per model — useful when you are deciding whether to downgrade a model for short posts.
  • Export the whole provenance table as JSON for a client audit, using a capability-gated admin_post handler that streams a file rather than printing to the page.
  • If you accept content from outside contributors, pair this with a REST route that requires an application password and writes provenance on their behalf.

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 Encrypt WordPress AI Plugin API Keys at Rest with Sodium: Capability-Gated Decryption and Key Rotation

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

  • Encrypt WordPress AI Plugin API Keys at Rest with Sodium: Capability-Gated Decryption and Key Rotation Encrypt WordPress AI Plugin API Keys at Rest with Sodium: Capability-Gated Decryption and Key Rotation
  • AI Translation Workflow for WordPress Posts: Glossary-Aware LLM Drafts with Human Review and Locale Linking AI Translation Workflow for WordPress Posts: Glossary-Aware LLM Drafts with Human Review and Locale Linking
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