{"slug": "how-i-built-an-openai-compatible-prompt-encryption-proxy-in-300-lines-of-python", "title": "How I built an OpenAI-compatible prompt encryption proxy in 300 lines of Python", "summary": "A developer built an OpenAI-compatible prompt encryption proxy in 300 lines of Python using FastAPI and httpx. The proxy allows prompt sellers to host their prompts on a server, providing buyers with an API URL and key that injects the system prompt without exposing the text. The open-source Lite version is available on GitHub, while a Pro version offers encrypted storage and multi-tenant support.", "body_md": "I sell GPT prompts. Small business, $20-50 per prompt, mostly to people who use ChatBox or Cursor. Last month, one of my buyers screenshotted my prompt text and posted it in a Discord server. Within 24 hours, three other people were reselling it on Telegram. I felt sick.\n\nThe obvious fix — \"just don't ship the prompt text\" — sounded dumb until I realized that's exactly what every SaaS does. ChatGPT doesn't ship OpenAI's system prompt. Midjourney doesn't ship its prompt. They ship **access**.\n\nSo I built a tiny proxy. ~300 lines of Python, FastAPI, and httpx. OpenAI-compatible. Streaming. Bearer key auth. The prompt lives on my server, the buyer gets an API URL + key, they point their client at it, my server injects the system prompt, forwards to the AI provider, returns the response. The buyer never sees the prompt text.\n\nThis post walks through the architecture, the interesting bits, and a few mistakes I made along the way. The full Lite version is [open-source on GitHub](https://github.com/tlyyxjz/prompt-proxy-lite). The Pro version with encrypted storage + multi-tenant + admin panel is [self-host paid](https://github.com/tlyyxjz/prompt-proxy-pro) — but everything in this article is in the open-source Lite version.\n\n```\n[Buyer's ChatBox]                     [My server]                    [DeepSeek API]\n       |                                   |                                |\n       |  POST /v1/chat/completions        |                                |\n       |  Authorization: Bearer sk-...     |                                |\n       |  {model, messages: [{user,...}]}  |                                |\n       | --------------------------------> |                                |\n       |                                   | 1. verify bearer key           |\n       |                                   | 2. load prompt from config     |\n       |                                   | 3. prepend as system message   |\n       |                                   | 4. forward to upstream         |\n       |                                   | ------------------------------>|\n       |                                   |                                |\n       |                                   |  <--- response (stream ok) ----|\n       |                                   |                                |\n       |  <--- stream chunks --------------|                                |\n       |                                   |                                |\n```\n\nThe buyer's request is identical to what they'd send to OpenAI. The only difference is the URL and the API key.\n\nThe buyer is waiting for tokens. If you buffer the entire upstream response before returning, you've killed the UX.\n\n``` python\nasync def _stream_upstream(url, headers, payload):\n    timeout = httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0)\n    async with httpx.AsyncClient(timeout=timeout) as client:\n        async with client.stream(\"POST\", url, headers=headers, json=payload) as resp:\n            if resp.is_error:\n                body = await resp.aread()\n                err = json.dumps({\"error\": {\"code\": resp.status_code, \"message\": body.decode(\"utf-8\", \"replace\")}})\n                yield f\"data: {err}\\n\\n\"\n                yield \"data: [DONE]\\n\\n\"\n                return\n            async for line in resp.aiter_lines():\n                if not line:\n                    continue\n                if line.startswith(\"data:\"):\n                    yield line + \"\\n\\n\"\n                else:\n                    yield f\"data: {line}\\n\\n\"\n    yield \"data: [DONE]\\n\\n\"\n```\n\nFastAPI's `StreamingResponse`\n\nwill iterate this async generator and flush each chunk to the client immediately. No buffering. The buyer sees the first token as soon as the upstream emits it.\n\n**Mistake I made first:** I returned `response.aiter_raw()`\n\ndirectly without re-emitting the `data:`\n\nprefix. ChatBox silently ate the stream because it expected SSE format. Always re-emit in SSE format.\n\nThe buyer may have configured their own system prompt in ChatBox. You don't want to overwrite it — you want to prepend yours.\n\n``` python\ndef _inject_system_prompt(messages):\n    config = load_prompts()\n    system_text = (config.get(\"system_prompt\") or \"\").strip()\n    if not system_text:\n        return messages\n\n    if messages and messages[0].get(\"role\") == \"system\":\n        merged = {\n            \"role\": \"system\",\n            \"content\": system_text + \"\\n\\n\" + messages[0].get(\"content\", \"\"),\n        }\n        return [merged, *messages[1:]]\n\n    return [{\"role\": \"system\", \"content\": system_text}, *messages]\n```\n\n**Mistake I made first:** I appended my system message at the end of the message list. The model treated it as a user instruction and the buyer's prompt leaked through. Always prepend, always merge if there's already a system message.\n\n``` python\nimport secrets\nfrom fastapi import Header, HTTPException, status\n\nasync def verify_client_key(authorization: str | None = Header(default=None)) -> str:\n    if not authorization or not authorization.lower().startswith(\"bearer \"):\n        raise HTTPException(status_code=401, detail=\"Missing or invalid Authorization header\")\n    token = authorization[7:].strip()\n    for configured in settings.client_keys:\n        if secrets.compare_digest(token, configured):\n            return token\n    raise HTTPException(status_code=401, detail=\"Invalid API key\")\n```\n\n`secrets.compare_digest`\n\nis constant-time. `token == configured`\n\nis not — it short-circuits on the first different byte, which leaks key length and prefix info via timing. For 32-character API keys this is a minor risk, but it's free to do correctly.\n\nSome buyers will configure `gpt-4`\n\nin their client even though your upstream is DeepSeek. You can either reject the request or silently override:\n\n``` python\ndef _override_model(model):\n    forced = (load_prompts().get(\"force_model\") or \"\").strip()\n    return forced or model\n```\n\nI force the model. Buyers don't care which model runs, they care about the result. Forcing also means you can swap upstream providers without telling buyers.\n\nAfter shipping the Lite version, I realized I needed more to actually run a prompt-selling business:\n\n`cat prompts.yaml`\n\n. The Pro version uses AES-256-GCM encryption in SQLite.`clients`\n\ntable with per-client keys + expiry + active flag.`usage_logs`\n\ntable.These are all in the [Pro version](https://github.com/tlyyxjz/prompt-proxy-pro) (self-host, $49 personal / $99 team / $249 agency).\n\nAES-256-GCM with a random nonce per encryption. The encryption key is a 64-character hex string loaded from env. Each prompt save generates a new nonce.\n\n``` python\nfrom cryptography.hazmat.primitives.ciphers.aead import AESGCM\nimport secrets\n\n_NONCE_LENGTH = 12\n\ndef encrypt_prompt(plaintext: str, key: bytes) -> bytes:\n    nonce = secrets.token_bytes(_NONCE_LENGTH)\n    ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode(\"utf-8\"), None)\n    return nonce + ciphertext\n\ndef decrypt_prompt(ciphertext: bytes, key: bytes) -> str:\n    nonce = ciphertext[:_NONCE_LENGTH]\n    encrypted = ciphertext[_NONCE_LENGTH:]\n    return AESGCM(key).decrypt(nonce, encrypted, None).decode(\"utf-8\")\n```\n\n**Why AES-GCM and not AES-CBC?** GCM gives you authentication for free — the auth tag validates that the ciphertext hasn't been tampered with. With CBC you'd need to add HMAC separately. GCM is also faster in hardware.\n\n**Why random nonce per encryption?** If you reuse a nonce with the same key in GCM, you catastrophic-ally break confidentiality. `secrets.token_bytes(12)`\n\ngives you 96-bit nonces, which is the GCM standard.\n\n**Threat model:** Encryption protects against (1) database leak, (2) backup leak, (3) someone with file system access `cat`\n\n-ing the SQLite file. It does NOT protect against someone with root on the running process — at that point the key is in memory and you're done. The point is raising the bar from \"open the db file\" to \"compromise the running process\".\n\nThe Lite version is a single Dockerfile:\n\n```\nFROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\nEXPOSE 8000\nCMD [\"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n```\n\nBuild + run:\n\n```\ndocker build -t prompt-proxy-lite .\ndocker run -p 8000:8000 --env-file .env -v $(pwd)/prompts.yaml:/app/prompts.yaml prompt-proxy-lite\n```\n\nFor public internet exposure, put it behind Cloudflare Tunnel or Caddy. Don't expose FastAPI directly — it's not a TLS terminator.\n\n`/v1/chat/completions`\n\ncorrectly, you get compatibility with ChatBox, Cursor, LibreChat, Open WebUI, every Python/JS SDK, for free. This is a huge leverage point — you don't need to write a client.If you build something similar, I'd love to hear about it. The architecture is small enough that you can fork the Lite version and add your own twists — multi-model routing, prompt versioning, A/B testing, whatever.\n\nIf you found this useful, follow me on [Twitter](https://twitter.com/) where I post about indie hacking + Python + AI tooling. And if you want to sell prompts but don't want to build the proxy yourself, the Pro version is $49 for personal use.\n\nArticle by **tlyyxjz** — indie hacker, Python dev, building tools for the AI creator economy.", "url": "https://wpnews.pro/news/how-i-built-an-openai-compatible-prompt-encryption-proxy-in-300-lines-of-python", "canonical_source": "https://dev.to/tlyyxjz/devto-ji-zhu-chang-wen-ying-wen-shou-fa-ke-repost-medium-hashnode-4h69", "published_at": "2026-07-17 07:56:39+00:00", "updated_at": "2026-07-17 08:01:05.130935+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-infrastructure", "artificial-intelligence"], "entities": ["OpenAI", "FastAPI", "httpx", "ChatBox", "Cursor", "Midjourney", "DeepSeek", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/how-i-built-an-openai-compatible-prompt-encryption-proxy-in-300-lines-of-python", "markdown": "https://wpnews.pro/news/how-i-built-an-openai-compatible-prompt-encryption-proxy-in-300-lines-of-python.md", "text": "https://wpnews.pro/news/how-i-built-an-openai-compatible-prompt-encryption-proxy-in-300-lines-of-python.txt", "jsonld": "https://wpnews.pro/news/how-i-built-an-openai-compatible-prompt-encryption-proxy-in-300-lines-of-python.jsonld"}}