Adding a chatbot to a personal site takes about twenty lines of JavaScript. Most tutorials that show you those twenty lines are handing your API key to every visitor who opens DevTools. Fixing that turns a credential problem into a spending problem, because the endpoint you build to hold the key is one a stranger can call all day. This lab solves both, on shared hosting, for free.

What you'll build

A server-side proxy that holds an inference API credential and caps what it can cost you. By the end you'll be able to:

  • Explain why a browser-side API key is unfixable rather than untidy
  • Store a credential where the web server structurally cannot serve it
  • Write a proxy that enforces two independent spend limits
  • Test the result, and understand which of those limits is the one doing real work

There's no chat interface here — curl is the client. That keeps the focus on the server, which is where every decision that matters lives.

Build time is around 30 minutes.

Prerequisites

  • A web host running PHP with outbound cURL. Most shared hosting qualifies
  • Local Ubuntu or WSL for testing
  • A free API key from an inference provider. This lab uses Groq's free tier because it needs no credit card, which means a mistake costs nothing
  • Comfort editing files on the server, over SFTP or through a file manager

No frameworks, no build step.

Why the obvious approach fails

Here's the twenty-line version:

// Don't ship this.
const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer gsk_live_your_actual_key_here"
  },
  body: JSON.stringify({ model: "llama-3.3-70b-versatile", messages })
});

It works. It also publishes your key.

The instinct is to hide it — split the string across variables, base64 it, fetch it from a config endpoint, minify the bundle. None of that helps, and understanding why it can't help is the foundation for everything else here.

Your JavaScript runs on hardware the visitor controls. For that request to leave their browser with a valid Authorization header, the header has to exist in their machine's memory in plaintext at the moment of the call. Every obfuscation scheme is a set of instructions for reconstructing the key, shipped alongside the key. The visitor's own browser does the work: open DevTools, watch the Network tab, read the header. No tooling, no exploit, no skill required.

The general form is worth internalising well beyond chatbots — a secret the client must possess in order to function is not a secret. The only fix is architectural. Something the visitor doesn't control has to hold the credential and make the call.

Two trust boundaries

Boundary The question Failure mode
Credential Who holds the key? Key theft; someone else's bill
Spend Who can trigger inference? Denial-of-wallet; quota exhaustion

They're in that order because solving the first creates the second. Move the key server-side and you've built a public endpoint that turns HTTP requests into billable inference. The key is safe; the quota isn't. Solve both or you've moved the problem rather than fixing it.

The architecture:

browser ──POST /chat.php──▶ your server ──▶ inference API
                                │
                     key read from outside webroot

The browser talks only to your own domain. Your server holds the credential, enforces limits, and is the only thing that ever sees the key.

Background: what the API call actually is

If you haven't worked with a language model API before, three things matter before you write the proxy.

Messages and roles. You send a list of messages, each tagged system, user, or assistant. The system message sets behaviour; user messages are the human's turns; assistant messages are the model's previous replies. The model has no memory between calls — the entire conversation is re-sent every time. That's why the proxy caps history length: every turn you keep costs tokens on every subsequent request.

Tokens. Models process text in chunks called tokens, roughly ¾ of a word in English. Rate limits and billing are measured in tokens, not characters, and both your input and the model's output count toward them. Capping message length is a cost control, not a UX nicety.

Temperature. A number, usually 0 to 1, controlling how much randomness enters word selection. Lower is more deterministic. This lab uses 0.4.

One consequence shapes the code below: a system prompt is not a security control. It's a strong suggestion sitting in the same input stream the visitor writes to, with no privilege separation between your instructions and theirs. Anything that must actually hold gets enforced in PHP, outside the model.

Hands-on

Step 1 — Prove your host can reach the API

Some shared hosts block outbound HTTP from PHP. Find out before building on top of the assumption. Put this at public_html/curltest.php:

<?php
// Does cURL exist, and can it reach the outside world?
var_dump(function_exists('curl_init'));
$ch = curl_init('https://api.groq.com/openai/v1/models');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
var_dump(curl_getinfo($ch, CURLINFO_RESPONSE_CODE), curl_error($ch));

Load it in a browser. You want bool(true), then int(401), then an empty error string. The 401 is the good outcome — it means the connection reached the API and was rejected for having no key, which is exactly what you sent. A response code of 0 with a connection error means outbound requests are blocked, and you need a different host or a serverless proxy.

Delete this file immediately after. It's an unauthenticated endpoint that makes outbound requests on demand.

Step 2 — Put the key where the web server cannot serve it

The key goes in a file above the web root. Apache serves public_html and its children; anything above has no URL at all. There's no .htaccess rule to get wrong and no directory listing to leak, because the path is structurally unreachable rather than protected.

mkdir -p ~/private
printf '%s' 'gsk_your_key_here' > ~/private/groq_key.txt
chmod 600 ~/private/groq_key.txt

Use printf rather than echo. echo appends a newline, and a trailing newline inside an Authorization header produces a confusing 401 much later, long after you've stopped suspecting the key file.

No shell access? Do the same in a file manager: navigate one level above public_html, create the private folder and the file inside it, set permissions to owner read/write only.

Verify the path your proxy will use, with a temporary file in public_html:

<?php
echo 'key expected at: ' . dirname(__DIR__) . "/private/groq_key.txt\n";

That path must match where you actually put the key. Delete this one too.

Step 3 — Write the proxy

This is the whole server side. It reads the key, enforces two limits, forwards the conversation, and returns only the reply text.

<?php
declare(strict_types=1);

// ---------------------------------------------------------------- config

$KEY_FILE = dirname(__DIR__) . '/private/groq_key.txt';  // outside the webroot
$API_URL  = 'https://api.groq.com/openai/v1/chat/completions';
$MODEL    = 'llama-3.3-70b-versatile';

$ALLOWED_ORIGINS = ['https://example.com', 'https://www.example.com'];

$IP_LIMIT     = 20;   // messages per visitor per day
$GLOBAL_LIMIT = 400;  // messages site-wide per day
$MAX_CHARS    = 600;  // per message
$MAX_HISTORY  = 6;    // previous messages re-sent for context
$TIMEOUT      = 30;

$SYSTEM_PROMPT = <<<'TXT'
You are an AI assistant on a personal blog about security and AI engineering.

- You are an AI system, not a person. Never claim or imply otherwise.
- Keep answers under 150 words unless asked for more.
- If you don't know something, say so. Never invent post titles or URLs.
- Plain text only. No markdown headings, no tables.
- Don't ask visitors for personal details.
TXT;

// ---------------------------------------------------------------- helpers

function fail(int $status, string $message): never
{
    http_response_code($status);
    echo json_encode(['error' => $message], JSON_UNESCAPED_SLASHES);
    exit;
}

// Daily counter in a JSON file. Returns false once the limit is passed.
function within_limit(string $bucket, int $limit): bool
{
    $path  = sys_get_temp_dir() . '/chat_' . preg_replace('/[^a-z0-9_]/i', '', $bucket) . '.json';
    $today = gmdate('Y-m-d');

    $fh = @fopen($path, 'c+');
    if ($fh === false) {
        return true;  // can't track — fail open rather than break the endpoint
    }
    flock($fh, LOCK_EX);

    $data = json_decode(stream_get_contents($fh) ?: '', true);
    if (!is_array($data) || ($data['date'] ?? '') !== $today) {
        $data = ['date' => $today, 'count' => 0];
    }

    $data['count']++;
    $ok = $data['count'] <= $limit;

    ftruncate($fh, 0);
    rewind($fh);
    fwrite($fh, json_encode($data));
    fflush($fh);
    flock($fh, LOCK_UN);
    fclose($fh);

    return $ok;
}

// ---------------------------------------------------------------- guards

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    fail(405, 'Use POST.');
}

$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && !in_array($origin, $ALLOWED_ORIGINS, true)) {
    fail(403, 'Origin not allowed.');
}

$key = @file_get_contents($KEY_FILE);
if ($key === false || trim($key) === '') {
    fail(500, 'The assistant is not configured yet.');
}
$key = trim($key);

$body = json_decode(file_get_contents('php://input') ?: '', true);
if (!is_array($body)) {
    fail(400, 'Expected a JSON body.');
}

$message = trim((string)($body['message'] ?? ''));
if ($message === '') {
    fail(400, 'Message was empty.');
}
if (mb_strlen($message) > $MAX_CHARS) {
    fail(400, "Keep messages under {$MAX_CHARS} characters.");
}

$ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown';

// Global limit first: it protects the quota even against a distributed flood.
if (!within_limit('global', $GLOBAL_LIMIT)) {
    fail(429, 'The assistant has hit its daily limit. Try again tomorrow.');
}
if (!within_limit('ip_' . md5($ip), $IP_LIMIT)) {
    fail(429, "You've reached today's message limit.");
}

// ---------------------------------------------------------------- request

$messages = [['role' => 'system', 'content' => $SYSTEM_PROMPT]];

// Trust nothing from the client: the role is whitelisted, not passed through.
foreach (array_slice((array)($body['history'] ?? []), -$MAX_HISTORY) as $turn) {
    $role = ($turn['role'] ?? '') === 'assistant' ? 'assistant' : 'user';
    $text = trim((string)($turn['content'] ?? ''));
    if ($text !== '') {
        $messages[] = ['role' => $role, 'content' => mb_substr($text, 0, $MAX_CHARS)];
    }
}

$messages[] = ['role' => 'user', 'content' => $message];

$ch = curl_init($API_URL);
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => $TIMEOUT,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $key,
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'model'       => $MODEL,
        'messages'    => $messages,
        'max_tokens'  => 400,
        'temperature' => 0.4,
    ]),
]);

$response = curl_exec($ch);
$status   = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlErr  = curl_error($ch);
curl_close($ch);

if ($response === false) {
    error_log('chat curl failure: ' . $curlErr);
    fail(502, 'Could not reach the model. Try again in a moment.');
}

$parsed = json_decode($response, true);

// Log the upstream detail; return something generic to the caller.
if ($status !== 200) {
    error_log('chat upstream ' . $status . ': ' . substr($response, 0, 500));
    fail(502, $status === 429
        ? 'The model is rate limited right now. Try again in a minute.'
        : 'The model returned an error.');
}

$reply = trim((string)($parsed['choices'][0]['message']['content'] ?? ''));
if ($reply === '') {
    fail(502, 'The model returned an empty reply.');
}

echo json_encode(['reply' => $reply], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

Four decisions in there deserve explanation.

Two limits, not one. $IP_LIMIT stops one bored visitor. $GLOBAL_LIMIT is the circuit breaker that survives a flood from many addresses, because per-IP limiting collapses the moment someone brings a proxy pool. The global cap is the one actually protecting your quota — set it to a number you'd be relaxed about burning in a day, then treat the per-IP limit as politeness.

History roles are whitelisted, not passed through. The client sends conversation history, so the client can lie about it. Anything that isn't exactly assistant becomes user. Without that line, a crafted request could inject a fake system turn and rewrite the model's instructions from the browser.

Errors are asymmetric. Upstream status and response body go to error_log; the caller gets a generic string. Provider error responses can carry account identifiers and quota details you have no reason to publish.

Failing open on the limiter is a deliberate trade. If the counter file can't be opened, requests proceed. A broken temp directory silently disabling the endpoint is the worse outcome for a personal site — but it is a trade, and if the spend ceiling matters more than availability, invert it.

What you should see

curl -sS -X POST https://example.com/chat.php \
  -H 'Content-Type: application/json' \
  -d '{"message":"What is this site about?"}'

A JSON object with a reply key. If you get "The assistant is not configured yet.", the $KEY_FILE path doesn't match where the key actually is. On a 502, check the PHP error log — the proxy deliberately writes the upstream status and response body there.

Attack your own build

Building it is half the lab.

1. Steal the key. Load anything on your site that calls the endpoint, then open DevTools. Read the page source, every script, and every request in the Network tab. The Authorization header isn't there — the browser only ever talks to /chat.php. Compare that against the twenty-line version at the top of this post, where the key is in the first request you inspect.

2. Exhaust the quota. Fire requests in a loop and watch the limiter engage:

for i in $(seq 1 25); do
  curl -sS -X POST https://example.com/chat.php \
    -H 'Content-Type: application/json' \
    -d '{"message":"hi"}' | head -c 120; echo
done

Replies, then a 429. Now think about the same 25 requests arriving from 25 addresses — the per-IP counter never fires, and only $GLOBAL_LIMIT stands between a stranger and your daily quota. That's why it's the number worth tuning.

3. Bypass the origin check. Notice that every curl above sends no Origin header at all, and the proxy accepts it. That's intentional: non-browser clients don't send one, and rejecting them would break your own testing. The check stops a browser on someone else's domain from embedding your endpoint. It is not authentication, and treating it as authentication would be the mistake.

What this does not protect against

  • Anyone can use your endpoint. It's public by design. The limits cap the damage; they don't prevent use
  • Visitor messages leave your infrastructure. They go to a third-party provider, in another jurisdiction. That's a GDPR question, and your privacy notice should state what's sent and to whom
  • Whatever renders the reply. The text coming back is attacker-influenceable and lands in someone's browser. That's a separate boundary with its own failure mode, and this proxy does nothing about it

Key takeaways

  • A secret the client must possess to function is not a secret. Obfuscation ships the key alongside the instructions for recovering it
  • Moving a credential server-side converts key exposure into a spend problem. Both need solving
  • Per-IP limits are cosmetic against anyone who cares. The global cap is the real control
  • Anything arriving from the client — including conversation history — is attacker-controlled and needs whitelisting, not pass-through
  • A system prompt is a suggestion in the same channel the attacker writes to. Enforce real properties in code

Going further

  • Move the rate-limit counters out of the system temp directory. On shared hosting that path can be cleared without warning, which disables your spend ceiling silently — the worst way for a control to fail
  • Add structured request logging, then look at what strangers actually send an open inference endpoint. It's rarely what you designed it for
  • Read your provider's terms on rate limits before assuming a free tier is a safety net. Some degrade; some queue; some bill