The short answer is that you do not secure a local AI endpoint by hiding it behind a WordPress page. You secure it by putting a TLS-terminating reverse proxy in front of the runtime, giving that proxy its own bearer token, and letting WordPress call it from PHP only — never from the browser. The API key lives in wp-config.php or an environment variable, the browser authenticates to WordPress with a cookie and a REST nonce, and the local model never sees a public request.
This guide is for a technically curious beginner or intermediate WordPress developer running a local model on the same machine, a LAN host, or a private VPS. It assumes you can edit a plugin file and a reverse-proxy config. Where a claim depends on a specific runtime’s defaults, treat it as something to confirm against that runtime’s own documentation rather than as a universal rule.
The threat model: what you are actually protecting
Three distinct assets are at risk, and they need different controls.
- The API key or proxy token. If it reaches the browser, anyone who opens DevTools can copy it. A widely shared write-up on the server-side proxy pattern puts it bluntly: a browser-side call means “your API key — and your bill — are one browser inspection away from being someone else’s problem.”
- The compute. A local model endpoint is not free to run. An unauthenticated listener on a routable address is an open invitation to saturate your GPU.
- The data. Prompts and completions may contain customer records, unpublished content, or internal notes. Interception or logging of that traffic is a privacy problem, not just a performance one.
A production-grade pattern for AI endpoints in WordPress — described in a 2026 write-up on secure proxies, caching, rate limits, and observability — frames the architecture as: frontend block or admin page → WordPress REST endpoint on the server → AI provider. Caching happens at the WordPress layer, rate limiting is per user or per site, and audit logs go to a custom table with PII minimization. That same shape applies whether the “provider” is a cloud API or a model running on your own hardware.
Prerequisites
- WordPress 6.4 or newer and PHP 8.1 or newer. These are the minimums cited in the production-pattern write-up; older versions may work but are untested territory.
- A local inference runtime reachable over HTTP, plus its base URL and port.
- A reverse proxy you control — Caddy, nginx, or Traefik — with a certificate.
- A persistent object cache such as Redis or Memcached if you plan to cache responses or count rate-limit hits. The write-up recommends it, and it matters: transients in the database get slow under load.
- Ability to edit
wp-config.phpor set environment variables on the host.
Step 1 — Keep the runtime off the public internet
Most local runtimes bind to the loopback interface by default. Ollama listens on 127.0.0.1:11434, LM Studio’s local server defaults to 127.0.0.1:1234, and llama-server from llama.cpp defaults to port 8080. Confirm the actual bind address in your runtime’s configuration before assuming it is private — some installers change it, and a container with host networking can publish a port you did not intend to publish.
The rule is simple: the runtime should listen on loopback only, and the reverse proxy should be the only thing that can reach it. If your model host is a different machine on the LAN, bind to the LAN interface and firewall it so that only the WordPress host can connect.
Step 2 — Terminate HTTPS in front of the local endpoint
WordPress’s HTTP API verifies TLS certificates by default, and you should keep it that way. Disabling sslverify to make a self-signed certificate work is the single most common mistake in this setup — it converts a fixable configuration problem into a permanent man-in-the-middle exposure.
A minimal Caddy configuration that terminates TLS, requires a bearer token, and forwards to a loopback runtime looks like this:
ai.internal.example.com {
@authorized header Authorization "Bearer {$AI_PROXY_TOKEN}"
handle @authorized {
reverse_proxy 127.0.0.1:11434
}
respond "Unauthorized" 401
}For a publicly resolvable hostname, Caddy obtains a certificate automatically. For an internal-only name, use tls internal and distribute the local CA to the WordPress host, or issue a certificate with mkcert during development. Either way, the WordPress server must trust the issuing CA — that is the correct fix for a certificate error, not sslverify => false.
If you put nginx in front instead, the equivalent controls are a listen 443 ssl server block, an if ($http_authorization != "Bearer ...") { return 401; } guard, and proxy_pass http://127.0.0.1:11434;. Add proxy_set_header X-Forwarded-Proto $scheme; so downstream services can tell the original request was HTTPS.
Step 3 — Store API keys outside the web root
Define secrets as constants in wp-config.php, sourced from the environment where possible:
define( 'AISP_AI_BASE_URL', 'https://ai.internal.example.com' );
define( 'AISP_AI_API_KEY', getenv( 'AISP_AI_API_KEY' ) ?: '' );Three rules follow from this:
- Never enqueue the key into a script, a localized JavaScript object, or a block attribute. Anything passed to
wp_localize_scriptorwp_add_inline_scriptis readable in the page source. - Never store the key in a WordPress option that is exposed through the REST API or the settings screen without masking.
- Rotate the proxy token on a schedule, and use a different token per environment. A staging token that matches production turns a test misconfiguration into a production incident.
Note that this is a live concern in the wider ecosystem: one practitioner write-up notes that WordPress 7.0 shipped native AI infrastructure in May 2026 and that security researchers immediately flagged API key exposure issues around the new connectors. The lesson generalizes — any integration that hands credentials to the front end is the wrong integration.
Step 4 — Build the WordPress proxy endpoint
Register a REST route with an explicit permission_callback. Omitting that argument produces a _doing_it_wrong notice and, more importantly, an open endpoint.
add_action( 'rest_api_init', function () {
register_rest_route( 'aisp/v1', '/generate', array(
'methods' => 'POST',
'callback' => 'aisp_handle_generate',
'permission_callback' => function ( WP_REST_Request $request ) {
if ( ! is_user_logged_in() ) {
return new WP_Error( 'aisp_unauthorized', 'Login required.', array( 'status' => 401 ) );
}
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error( 'aisp_forbidden', 'Insufficient capability.', array( 'status' => 403 ) );
}
return true;
},
) );
} );Inside the callback, forward the request server-side. WordPress’s HTTP API verifies TLS by default, so leave sslverify alone:
$response = wp_remote_post(
trailingslashit( AISP_AI_BASE_URL ) . 'v1/chat/completions',
array(
'timeout' => 60,
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . AISP_AI_API_KEY,
),
'body' => wp_json_encode( array(
'model' => 'local-model',
'messages' => $messages,
) ),
)
);Check is_wp_error( $response ) before reading the body, and return a sanitized error to the client. Do not echo the upstream response body verbatim — it can leak stack traces, model names, or internal hostnames.
Step 5 — Authenticate the browser to WordPress, not to the AI
Because the route requires a logged-in user, WordPress’s cookie authentication applies. When you call the endpoint with apiFetch from an admin screen or block editor context, the X-WP-Nonce header is added automatically. From a custom script, pass it explicitly:
fetch( '/wp-json/aisp/v1/generate', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce
},
body: JSON.stringify( { prompt: 'Draft a product summary.' } )
} );No AI credential appears anywhere in that snippet. That is the point.
Step 6 — Rate limit, cache, and log
Authentication stops strangers; it does not stop an authenticated editor from accidentally looping a request a thousand times. Add a per-user counter in the object cache with a short TTL, and reject requests above the threshold with a 429. Cache identical prompts in a transient keyed by a hash of the normalized input — the production-pattern write-up uses a req_hash column for exactly this purpose in its audit table.
Log the route, user ID, request hash, token counts, duration, status code, and error text to a custom table. Minimize PII: store the hash, not the prompt, unless you have a specific reason and a retention policy.
Verifying that the lock holds
Run these checks in order. Each one should fail in the expected way.
# 1. Runtime must not be reachable from outside the host
curl -sS --max-time 5 http://YOUR_PUBLIC_IP:11434/v1/models
# 2. Proxy must reject a request with no token
curl -sS -o /dev/null -w '%{http_code}n' https://ai.internal.example.com/v1/models
# expect 401
# 3. Proxy must accept a request with the token
curl -sS -H 'Authorization: Bearer YOUR_TOKEN'
https://ai.internal.example.com/v1/models
# 4. WordPress route must reject an anonymous caller
curl -sS -o /dev/null -w '%{http_code}n'
-X POST https://example.com/wp-json/aisp/v1/generate
# expect 401If check 1 returns a model list, stop and fix the bind address before doing anything else.
Common errors and what they mean
cURL error 60: SSL certificate problem— the WordPress host does not trust the CA that signed the proxy certificate. Install the CA bundle. Do not setsslverify => false.- HTTP 403 with
rest_cookie_invalid_nonce— the REST nonce expired or the request came from a different origin. Refresh the page and confirmcredentials: 'same-origin'. - HTTP 502 from the proxy — the reverse proxy is up but the runtime is not listening on the expected port. Check the bind address and the container’s port mapping.
- Mixed-content warnings in the browser — an asset or endpoint is still loaded over
http://. Fix the URL; do not relax the browser policy.
Tradeoffs worth naming
This pattern adds a hop, a certificate to renew, and a token to rotate. On a single developer laptop, a loopback-only runtime with no proxy at all is a defensible choice — the traffic never leaves the machine. The proxy becomes necessary the moment the runtime is reachable by anything other than the WordPress process itself, or the moment more than one person uses the site. Long-running generations also belong in a background job runner rather than a synchronous REST request, since PHP timeouts and proxy timeouts will otherwise cut off work that the model has already started.
A reasonable next step is to run the four verification commands above against your current setup and record the results. If any of them returns something unexpected, that is the finding worth bringing to the community — along with your WordPress version, PHP version, runtime and port, and the exact error string.
FAQ
Can I just use a WordPress nonce instead of an API key for the local endpoint?
No. A nonce authenticates the browser session to WordPress; it says nothing to the local runtime. The runtime or the reverse proxy in front of it still needs its own credential, because anything that can reach the runtime directly bypasses WordPress entirely.
My local model uses a self-signed certificate. How do I make WordPress accept it?
Install the issuing CA certificate into the WordPress host's trust store, or issue the certificate through a tool like mkcert whose root you can install. Setting sslverify to false disables protection against man-in-the-middle attacks and should be treated as a temporary debugging step at most.
Is a reverse proxy necessary if the model runs on the same machine as WordPress?
Not strictly. A runtime bound to 127.0.0.1 and called over plain HTTP from PHP on the same host is not exposed to the network. The proxy becomes necessary once the runtime is reachable from another machine, shared by multiple users, or needs a stable HTTPS URL for other services.
Why did my request return 403 rest_cookie_invalid_nonce?
The REST nonce expired, the request was sent without credentials, or it came from a different origin. Confirm the fetch call uses credentials: 'same-origin' and that the X-WP-Nonce header carries a freshly generated nonce.