Shipping an OpenAI key to the browser is the most common way WordPress sites leak credentials. A fetch() straight to api.openai.com puts the key in the page source, the network tab, browser caches and any analytics tool that records request URLs. Storing it in wp_options is barely better: it lands in database dumps, staging clones and backup archives that get emailed around. The fix is a small server-side proxy — a REST route that keeps the key in PHP, checks who is asking, throttles them, and returns only the completion text.
This tutorial builds that proxy properly: capability checks, nonce validation, per-user and per-IP transient rate limits, prompt sanitising, secret-free logging, and a JavaScript caller that never sees the key.
What you will need
- WordPress 6.4 or later with pretty permalinks enabled — the REST API needs them.
- PHP 8.1+ with the
opensslandcurlextensions, whichwp_remote_post()relies on. - An OpenAI project API key with a spend limit set in the OpenAI dashboard.
- Write access to
wp-config.php, or to your PHP-FPM pool / systemd unit file. - A plugin or mu-plugin folder to hold the code. Do not paste this into the
functions.phpof a theme you might switch later.
Step 1 — Keep the key out of the database
Constants live in a file, not in a table, so they never appear in an export. Read the value from the environment first and fall back to a hard-coded constant only on machines where you cannot set one.
// wp-config.php — above the 'stop editing' line.
if ( ! defined( 'OPENAI_API_KEY' ) ) {
$wptuts_key = getenv( 'OPENAI_API_KEY' );
if ( is_string( $wptuts_key ) && '' !== $wptuts_key ) {
define( 'OPENAI_API_KEY', $wptuts_key );
}
unset( $wptuts_key );
}
On a managed host, set the variable at the PHP-FPM pool level so it never touches the repository:
# /etc/php/8.3/fpm/pool.d/www.conf, then: systemctl reload php8.3-fpm
env[OPENAI_API_KEY] = "sk-proj-REPLACE_WITH_YOUR_KEY"
Rotate the key the moment it has appeared in a screenshot, a support ticket or a public repository. OpenAI keys are bearer tokens — anyone holding one can spend your budget.
Step 2 — Register the route with a real permission_callback
A permission_callback of __return_true is the single worst line in most proxy tutorials. It turns your site into an open relay for anyone who guesses the URL. Require a logged-in user, a capability, and a valid wp_rest nonce.
add_action( 'rest_api_init', function () {
register_rest_route(
'wptuts/v1',
'/ai/complete',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'wptuts_ai_complete',
'permission_callback' => 'wptuts_ai_permission',
'args' => array(
'prompt' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
'validate_callback' => function ( $value ) {
$len = strlen( (string) $value );
return $len >= 3 && $len <= 2000;
},
),
),
)
);
} );
function wptuts_ai_permission( WP_REST_Request $request ) {
if ( ! is_user_logged_in() ) {
return new WP_Error( 'wptuts_anon', 'Sign in first.', array( 'status' => 401 ) );
}
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error( 'wptuts_cap', 'Insufficient capability.', array( 'status' => 403 ) );
}
$nonce = $request->get_header( 'X-WP-Nonce' );
if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
return new WP_Error( 'wptuts_nonce', 'Invalid nonce.', array( 'status' => 403 ) );
}
return true;
}
Core already validates the X-WP-Nonce header for cookie-authenticated requests, but checking it explicitly documents the contract and keeps the route safe if you later add an application-password path. The validate_callback caps prompt length before any tokens are spent — a 2000-character ceiling is generous for a single turn and stops someone pasting a novel onto your invoice.
Step 3 — Rate limit per user and per IP
Transients are the cheapest throttle WordPress offers. Bucket by a fixed time window so the counter resets predictably, and track both the user ID and a hashed IP — a logged-in user on a shared office connection should not be blocked by a colleague, and an attacker should not be able to rotate accounts to reset the count.
function wptuts_ai_rate_limit( $user_id, $ip, $limit = 10, $window = 60 ) {
$bucket = (int) floor( time() / $window );
$keys = array(
'wptuts_rl_u_' . (int) $user_id . '_' . $bucket,
'wptuts_rl_ip_' . md5( $ip ) . '_' . $bucket,
);
foreach ( $keys as $key ) {
if ( (int) get_transient( $key ) >= $limit ) {
return new WP_Error(
'wptuts_rate_limited',
'Too many requests. Try again in a minute.',
array( 'status' => 429 )
);
}
}
foreach ( $keys as $key ) {
set_transient( $key, (int) get_transient( $key ) + 1, $window );
}
return true;
}
Ten requests per minute per identity is a sane default for interactive use. With a persistent object cache the transients live in Redis or Memcached and the check costs microseconds; without one they hit the options table, so keep the window short and the key count low. Never call set_transient() with no expiry for counters — you will accumulate dead rows forever.
Step 4 — Sanitise the prompt and call OpenAI server-side
Everything the browser sends is untrusted. Sanitise it, then build the upstream payload with wp_json_encode() so quotes and unicode cannot break the JSON body. Cap max_tokens — it is your hard cost ceiling per request.
function wptuts_ai_complete( WP_REST_Request $request ) {
if ( ! defined( 'OPENAI_API_KEY' ) || '' === OPENAI_API_KEY ) {
return new WP_Error( 'wptuts_nokey', 'Server not configured.', array( 'status' => 500 ) );
}
$user_id = get_current_user_id();
$ip = isset( $_SERVER['REMOTE_ADDR'] )
? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
: '0.0.0.0';
$limited = wptuts_ai_rate_limit( $user_id, $ip );
if ( is_wp_error( $limited ) ) {
return $limited;
}
$prompt = sanitize_textarea_field( $request->get_param( 'prompt' ) );
$response = wp_remote_post(
'https://api.openai.com/v1/chat/completions',
array(
'timeout' => 30,
'headers' => array(
'Authorization' => 'Bearer ' . OPENAI_API_KEY,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode(
array(
'model' => 'gpt-4o-mini',
'max_tokens' => 400,
'temperature' => 0.4,
'messages' => array(
array( 'role' => 'system', 'content' => 'You are a concise WordPress assistant.' ),
array( 'role' => 'user', 'content' => $prompt ),
),
)
),
)
);
if ( is_wp_error( $response ) ) {
wptuts_ai_log( $user_id, $ip, 0, 'transport_error' );
return new WP_Error( 'wptuts_upstream', 'Upstream request failed.', array( 'status' => 502 ) );
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || ! isset( $body['choices'][0]['message']['content'] ) ) {
wptuts_ai_log( $user_id, $ip, $code, 'upstream_' . $code );
return new WP_Error( 'wptuts_upstream', 'Upstream returned an error.', array( 'status' => 502 ) );
}
wptuts_ai_log( $user_id, $ip, $code, 'ok' );
return rest_ensure_response(
array( 'text' => wp_kses_post( $body['choices'][0]['message']['content'] ) )
);
}
Note what is not returned: the raw upstream body, the response headers, the key. Errors are flattened to a generic message so a misconfigured key never surfaces in the browser console.
Step 5 — Log requests without logging secrets
You need an audit trail to spot abuse and to answer the question of who burned 40,000 tokens last night. You do not need the prompts. Store the user, a salted IP hash, the HTTP status and an outcome code — nothing that is personal data in the clear.
function wptuts_ai_install_table() {
global $wpdb;
$table = $wpdb->prefix . 'wptuts_ai_log';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at DATETIME NOT NULL,
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
ip_hash CHAR(64) NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
outcome VARCHAR(32) NOT NULL DEFAULT '',
PRIMARY KEY (id),
KEY user_time (user_id, created_at)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
register_activation_hook( __FILE__, 'wptuts_ai_install_table' );
function wptuts_ai_log( $user_id, $ip, $status, $outcome ) {
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'wptuts_ai_log',
array(
'created_at' => current_time( 'mysql', true ),
'user_id' => (int) $user_id,
'ip_hash' => hash( 'sha256', $ip . wp_salt( 'auth' ) ),
'status' => (int) $status,
'outcome' => sanitize_key( $outcome ),
),
array( '%s', '%d', '%s', '%d', '%s' )
);
}
The salt makes the hash useless to anyone who steals only the table. Add a weekly cron that deletes rows older than 90 days and you stay on the right side of retention rules. Keep the log table out of any public export, and never write the prompt text into it — that is where personal data creeps in.
Step 6 — Call the proxy from JavaScript
Pass the endpoint and a fresh nonce to the script with wp_localize_script(). Never hard-code /wp-json/... — subdirectory installs and custom REST prefixes will break it.
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script(
'wptuts-ai',
plugins_url( 'assets/ai.js', __FILE__ ),
array(),
'1.0.0',
true
);
wp_localize_script(
'wptuts-ai',
'wptutsData',
array(
'endpoint' => esc_url_raw( rest_url( 'wptuts/v1/ai/complete' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
)
);
} );
const sendPrompt = async ( prompt ) => {
const res = await fetch( window.wptutsData.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.wptutsData.nonce,
},
body: JSON.stringify( { prompt } ),
} );
const data = await res.json();
if ( ! res.ok ) {
throw new Error( data.message || 'Request failed' );
}
return data.text;
};
document.querySelector( '#wptuts-ai-form' ).addEventListener( 'submit', async ( event ) => {
event.preventDefault();
const input = document.querySelector( '#wptuts-ai-prompt' );
const output = document.querySelector( '#wptuts-ai-output' );
output.textContent = 'Thinking…';
try {
output.textContent = await sendPrompt( input.value );
} catch ( error ) {
output.textContent = error.message;
}
} );
Use textContent, not innerHTML. The model output is untrusted text; assigning it as HTML reopens the exact injection hole you closed by moving the key server-side. If you genuinely need formatting, render it through a sanitiser such as DOMPurify first.
Troubleshooting
403 with ‘Cookie check failed’ or rest_cookie_invalid_nonce
The nonce expired — they are valid for 12–24 hours and are tied to the user session. If the page is served from a full-page cache, the nonce baked into the HTML goes stale for every visitor. Exclude that page from caching, or fetch a fresh nonce from a small uncached endpoint immediately before submitting.
401 even though you are logged in
You forgot credentials: 'same-origin', or the request is going to a different host than the one that set the auth cookie. Cross-origin calls need proper CORS handling — do not disable the cookie check to make it work.
429 straight away
Your transients are not expiring because a persistent object cache is misconfigured, or the window calculation is off. Inspect the key with get_transient() on staging and confirm the value increments and then resets.
502 from the proxy
Check the upstream status recorded in your log table. A 401 from OpenAI means the key is wrong or revoked; a 429 means you hit OpenAI’s own rate limit, not yours; a 400 usually means the model name has changed. Log the upstream status, never the upstream body.
The log table does not exist
dbDelta() is fussy: two spaces after PRIMARY KEY, one column per line, lowercase types. Deactivate and reactivate the plugin, or call the install function on admin_init guarded by a stored schema version option.
Next steps
- Move the route registration into a class and pass the model name through a filter, so staging can point at a cheaper model than production.
- Add a per-day token budget alongside the per-minute request limit — cost is the metric that matters, not request count.
- Expose the log table as a
WP_List_Tablescreen under Tools so support staff can spot abuse without touching the database. - Wrap the JavaScript in a small block so editors can drop the assistant into a post from the block editor rather than a hard-coded form.
- Read the custom endpoint documentation and the nonce reference before extending the route.