I’ve been building a side project that uses a large language model to generate summaries of user-uploaded documents. The idea was simple: send text to the API, get a summary back, display it in the UI. What could go wrong? Let me tell you about the two times I accidentally leaked my API key before I finally built a proper proxy.
The First Leak: The Commit
I was prototyping, so I threw the API key directly into a JavaScript file. I committed it to a private repo. Later, I pushed to GitHub. Within minutes, I got a friendly email from a security scanner: “We found an API key in your repository.” I rotated the key, removed the commit, and felt dumb. Lesson one: never hardcode keys. But I still needed to call the API from my frontend.
The Second Leak: The Client-Side Call
My frontend is a React SPA. I thought, “I’ll just store the key in an environment variable and pass it to the fetch call.” Sure, the key was now in a .env file, but it still ended up in the browser’s global scope at build time. Any user could open DevTools, inspect the network request, and see the key in the Authorization header. I realized that client-side secrets are not secrets. I rotated the key again, this time feeling more pain.
The Dead-End: CORS and Rate Limits
I considered using a third-party gateway that abstracts the AI API. I tried a service (like ai.interwestinfo.com) that promised seamless AI integration. It worked for a few hours, but then I hit rate limits that I couldn’t control, and the pricing didn’t align with my hobby project. Also, I didn’t want to depend on an external service for something I could build myself. So I decided to roll my own proxy.
The Approach: A Serverless Proxy
A proxy sits between my frontend and the AI API. It receives requests from my frontend, adds the API key server-side, calls the AI API, and returns the response. Crucially, the API key never touches the browser. I chose Cloudflare Workers because they deploy globally, have a generous free tier, and are easy to set up with custom domains.
The Code
Here’s the full Worker script I use. It’s about 50 lines, handles CORS, rate limiting, and authentication for the proxy itself (so not anyone can call it).
// proxy-worker.js
// Set these in Cloudflare Workers secrets (not in code)
const AI_API_KEY = ''; // will be set via environment variable
const PROXY_SECRET = ''; // a token you share with your frontend
// If using a managed service like ai.interwestinfo.com, replace the API_URL
const AI_API_URL = 'https://api.openai.com/v1/chat/completions';
export default {
async fetch(request, env, ctx) {
// 1. Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Proxy-Secret',
},
});
}
// 2. Verify proxy secret
const proxySecret = request.headers.get('X-Proxy-Secret');
if (proxySecret !== env.PROXY_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
// 3. Only allow POST
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
// 4. Rate limiting (simple in-memory per IP — not perfect but okay for small scale)
const ip = request.headers.get('CF-Connecting-IP');
const rateLimit = env.RATE_LIMIT || 10; // requests per minute
// (In reality you'd use a durable object or KV, but for brevity this is a placeholder)
// 5. Forward to AI API
try {
const body = await request.json();
const response = await fetch(AI_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.AI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
// 6. Return with CORS headers
return new Response(JSON.stringify(data), {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
},
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Proxy error' }), { status: 500 });
}
},
};
On the frontend, I call my worker endpoint like this:
const response = await fetch('https://my-proxy.workers.dev', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Proxy-Secret': 'my-secret-token',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: text }],
}),
});
Note: The X-Proxy-Secret token is stored in the frontend environment and can be rotated. It’s not as sensitive as the API key itself, but it prevents random internet crawlers from abusing my proxy.
Lessons Learned / Trade-offs
- Security: The API key never leaves the server. However, I now have a proxy secret that could be extracted from DevTools. That’s still better than exposing an OpenAI key, because the proxy secret can be scoped and rate-limited per user. Next iteration: add user authentication (JWT) to tie requests to logged-in users.
- Latency: Adding an extra hop adds about 10-30ms for me, which is negligible. But if you’re making many calls, consider batching.
- Cost: Cloudflare Workers free tier gives 100k requests/day. I’m well within that. The AI API costs remain the same.
- Rate Limiting: My simple in-memory rate limiter resets per worker invocation — not reliable. For production, use Cloudflare KV or a Durable Object. For my side project, it’s fine.
- Maintenance: I now have to deploy and monitor another service. But it’s just a few lines of code; updates are low friction.
What I’d Do Differently Next Time
If I were starting over, I’d still build a proxy, but I’d plan for user authentication from day one. I’d also design the proxy to accept a user ID and log requests per user for debugging. And I’d probably use a more robust rate-limiting strategy from the start, maybe based on Cloudflare’s Rate Limiting product.
For a production app with hundreds of users, I’d consider a dedicated backend service (like a Node.js/Express server) instead of a Worker, to have more control over request queuing and caching. But for a hobby project, the Worker is perfect.
So that’s my story of double-leaking my API key and the simple solution that saved my sanity. What’s your setup look like? Do you roll your own proxy, use a managed service, or have you found another way to keep secrets safe when calling AI APIs from the frontend? I’d love to hear what’s worked (or failed) for you.
Top comments (0)