I built a small offline-first, collaborative AI dashboard called AgentHub — React + Yjs (CRDTs) on the front, a Node/Express proxy on the back. It worked great on my laptop.
Then I went to open-source it, read my own backend like a stranger would, and realized the "secure" proxy I was so proud of was a wide-open ATM for my API key.
Here's the build, the bug, and the exact code that fixed it — all stealable.
The architecture, in one breath
Three constraints drove the whole thing:
- Works offline — state persists locally, network is optional.
- Conflict-free collaboration — two people, same state, no merge hell.
- API key never reaches the browser — the client talks to my server, my server talks to the model.
Yjs handles the first two almost for free. CRDTs (Conflict-free Replicated Data Types) merge concurrent edits deterministically, so I never write merge logic. The client setup is genuinely this small:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
const doc = new Y.Doc();
// 1) Offline-first: restore + persist local state in IndexedDB
new IndexeddbPersistence('agent-crdt-state-v1', doc);
// 2) Real-time: join a room over WebSocket and sync conflict-free
new WebsocketProvider(WS_URL, 'agent-crdt-state-v1', doc);
Pull the network cable, keep editing, plug it back in — it reconciles. No spinner, no data loss. The first time you see it, it feels illegal.
The third constraint is where I fooled myself
I kept the API key off the client. The browser calls POST /groq, my server injects the key and forwards to the model:
app.post('/groq', async (req, res) => {
const { prompt } = req.body;
const upstream = await fetch(GROQ_OPENAI_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${GROQ_API_KEY}`, // key stays server-side
},
body: JSON.stringify({ model: GROQ_MODEL, messages: [{ role: 'user', content: prompt }] }),
});
// ...forward response
});
"Key's off the client. I'm secure." Pat on the back. Push to GitHub.
Except — look at what's missing. No auth. No rate limit. CORS open to the entire internet. That endpoint is a public, unmetered pipe to my paid API key. Anyone who finds the URL can drain it from any website on Earth.
I'd locked the front door and left the vault open.
Fixing it — three small middlewares
None of this needs a library. Here's the whole hardening pass.
1. CORS allowlist (not cors() with no args):
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '')
.split(',').map(o => o.trim()).filter(Boolean);
app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : undefined));
if (!ALLOWED_ORIGINS.length) {
console.warn('[proxy] ALLOWED_ORIGINS unset — CORS open to all origins.');
}
The loud warning matters. I will forget to set the env var; I want the server yelling at me when I do.
2. Per-IP rate limiting (in-memory, zero deps):
const buckets = new Map();
function rateLimit(req, res, next) {
const ip = req.ip || 'unknown';
const now = Date.now();
const hits = (buckets.get(ip) || []).filter(t => now - t < WINDOW_MS);
if (hits.length >= MAX_HITS) {
return res.status(429).json({ error: 'Too many requests, slow down.' });
}
hits.push(now);
buckets.set(ip, hits);
next();
}
A sliding window per IP. One bad actor can't empty the key in an afternoon. (For multi-instance deploys you'd back this with Redis — but for a single node, a Map is honest and enough.)
3. Optional bearer-token gate:
function requireAuth(req, res, next) {
if (!PROXY_AUTH_TOKEN) return next(); // disabled unless you set it
const token = (req.headers.authorization || '').replace('Bearer ', '').trim();
if (token !== PROXY_AUTH_TOKEN) return res.status(401).json({ error: 'Unauthorized' });
next();
}
Then chain them onto the route:
app.post('/groq', rateLimit, requireAuth, async (req, res) => { /* ... */ });
Bonus footgun I also found: the original handler logged every prompt and the full upstream response to the console. Fine locally, a privacy leak in production. I gated all of it behind a DEBUG_GROQ flag.
⚠️ Browser-app caveat: any token you ship to the browser is visible to users. For a purely public web app, lean on
ALLOWED_ORIGINS+ rate limiting; usePROXY_AUTH_TOKENfor server-to-server or trusted clients.
"Open-source ready" is more than git push
The code was the easy half. The repo only felt honest once it had:
- A real LICENSE ("it's on GitHub" is not a license).
- A SECURITY.md that's upfront about the proxy's risks.
- A CONTRIBUTING.md so the next dev isn't reverse-engineering my setup.
- A copy-pasteable Quick Start (running in under 5 minutes).
- A guarantee no secret ever touched git history — the whole history, not just HEAD.
That last one deserves a billboard: a key that was ever committed is compromised, even if you delete it later. Grep your full history before you publish, not after.
Takeaway
The CRDT magic was the fun part. The lesson that'll outlive the project is duller and more useful:
Shipping for yourself and shipping for strangers are two different jobs. The second one is mostly respect — for the next dev's time, and for everyone who trusts your code enough to run it.
AgentHub is MIT-licensed and live. If offline-first sync or CRDTs are on your "I should learn that" list, it's a small, readable place to start:
👉 https://github.com/payallenka/AgentHub
Found a hole I missed? Open an issue. That's the whole point of putting it out there.
If this was useful, a ❤️ or 🦄 helps it reach the next dev. What's the worst "secure" code you've shipped? I'll go first — see above.
Top comments (1)
The "locked the front door, left the vault open" line is going to stick with me. This failure mode is so common because keeping the key off the client feels like the whole job, and the unauthenticated relay sitting behind it never shows up in local testing. It only bites once a stranger finds the URL.
One thing I'd add weight to: your git-history point is the one people underestimate most. Deleting a key from history doesn't un-leak it. Anyone who cloned or forked between the commit and the cleanup still has it, and bots scrape new public repos for live keys within minutes. So the rule isn't "scrub history," it's "rotate the key, then scrub" — treat any key that ever touched a commit as already burned.
Small note on the in-memory rate limiter: since the Map resets on restart, a crash-loop (or an attacker who can trigger restarts) quietly resets everyone's bucket to zero. Totally fine for a single hobby node, just worth knowing the failure mode before it goes anywhere real.
The committed-credential thing is easily the most common issue I run into as well. When we scan public MCP and agent servers at mcpsafe.io, a key sitting in source or history is consistently near the top of what turns up across the servers we look at. Your instinct to read your own backend "like a stranger would" is exactly the right move, and almost nobody actually does it.