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
hashextension (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 '—';
return;
}
$total = (int) $record['tokens_in'] + (int) $record['tokens_out'];
printf(
'<span title="%s">%s · %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 thatsanitize_key()reduces to an empty string. - Baseline contains escaped quotes — you skipped
wp_slash()on write, or appliedwp_unslash()twice on read. Read withget_post_meta()and do not unslash again. - Every hash changed after a migration —
wp_salt()is derived fromAUTH_KEYand 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_byandreviewed_atpair 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.phpfor a native side-by-side. - Query the meta with
WP_Queryand ameta_keyto 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_posthandler 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.