I'd already written a URL shortener for my VPS, a tiny Node.js server that maps aliases to URLs with a JSON file for storage. That was the one piece I'd built myself before this session. Everything else that follows (the pastebin, the security hardening, the API auth, the admin panel) happened in one afternoon with Claude Code.
What followed was about 90 minutes of building, hardening, and shipping, three distinct phases, all in one Claude Code (Opus 4.6) session. By the end: a new pastebin service built from scratch, both services security-hardened, API key auth on all write endpoints, and a combined admin panel. Around 1,700 lines of code across 14 files, deployed and tested on production.
Here's how the afternoon went.
Phase 1: The Pastebin (~3:00 PM)
The shortlinks service was already running, a simple Node.js server using the raw http module, no Express, storing alias-to-URL mappings in a JSON file. nginx proxies traffic to it, Cloudflare sits in front. Boring stack, works great.
I wanted a pastebin with the same philosophy: minimal dependencies, JSON storage, deploy-it-and-forget-it simple. Claude and I built it from a blank file.
The feature set:
- Create, view, and delete pastes
- Raw view endpoint for piping content to
curl - Syntax highlighting options
- Configurable expiry (1 hour, 1 day, 7 days, 30 days, or never)
- Bootstrap 5 frontend with a create page and a view page
Paste IDs are 8-character random strings using a safe alphabet, no ambiguous characters like 0/O or 1/l. Each paste also gets a 64-character hex delete token at creation, which you need to delete it. The token is returned once when the paste is created and never shown again.
Storage uses atomic writes: write to a temp file, then rename into place. This avoids the classic "server crashes mid-write and now your JSON file is truncated" problem. The shortlinks service uses the same pattern.
890 lines, 4 files. Deployed behind nginx as a systemd unit.
Time: about 20 minutes from blank file to running in production.
Phase 2: Security Hardening (~3:20 PM)
Now I had two services running with the same architecture but zero hardening. No rate limiting, no security headers, and a couple of subtle bugs in shared patterns. Claude and I went through both codebases together.
Rate Limiting
We added per-IP rate limiting to both services: a lower limit for write operations and a higher one for reads. The implementation tracks request counts in a simple in-memory map with automatic stale bucket pruning so it doesn't leak memory over time.
The rate limiter needed the real client IP, which meant configuring nginx to pass the X-Real-IP header through the proxy so the Node servers aren't rate-limiting nginx's loopback address.
Security Headers
Every response now includes X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Cache-Control headers. These are cheap to add and close off entire classes of attacks (MIME sniffing, clickjacking, referer leakage).
Fixing the RNG
Both services generated random IDs the same way, and both had a subtle bias in the random character selection. We switched to crypto.randomInt() which produces uniformly distributed values. The old approach would have worked fine in practice for an 8-character ID space, but there's no reason to leave a known imperfection in place when the fix is one line.
Input Validation and Prototype Pollution
We added URL protocol allowlists (only http:// and https:// for shortlinks), alias format validation, and reserved key blocking to prevent prototype pollution via keys like __proto__ or constructor. The .hasOwnProperty() calls were also fixed to use the safe Object.prototype.hasOwnProperty.call() form.
Timing-Safe Token Comparison
The pastebin's delete token check used a plain === comparison, which can leak timing information. We switched to crypto.timingSafeEqual. On a personal pastebin this is arguably overkill, but it's the kind of habit worth building.
212 lines changed across 3 files. About 20 minutes.
Phase 3: API Auth + Admin Panel (~4:00 PM)
With two hardened services running, the next problem was obvious: both were still wide open for writes. Anyone who found the endpoints could create pastes or shortlinks. Reads should stay public (that's the whole point), but writes needed a gate.
The Auth Model
We went with the simplest thing that's actually secure: a single shared API key, stored as an environment variable, validated via the X-API-Key header. Both services share the same key function:
// Returns: true = valid/unconfigured, false = invalid, null = missing
function checkApiKey(req) {
if (!API_KEY) return true; // No key configured, bypass auth
const reqUrl = new URL(req.url, 'http://localhost');
const provided = req.headers['x-api-key']
|| reqUrl.searchParams.get('api_key') || '';
if (!provided) return null;
const keyBuf = Buffer.from(provided);
const expectedBuf = Buffer.from(API_KEY);
if (keyBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(keyBuf, expectedBuf) || false;
}
The three-state return (true/false/null) lets each endpoint handle auth differently. Write endpoints reject on null (401) and false (403). The paste delete endpoint is more nuanced: if no API key is provided, it falls through to the per-paste delete token check, so public delete links still work. If a valid API key is provided, it bypasses the token entirely, admin mode. The ?api_key= query param fallback handles situations where setting headers is awkward.
The timing-safe comparison here uses the same crypto.timingSafeEqual we added in Phase 2. A naive === leaks information about how many characters matched based on response time. One-line difference in code, real side-channel closed.
Admin Endpoints
We added four new endpoints across the two services:
| Endpoint | Service | What It Does |
|---|---|---|
GET /api/pastes/list |
Pastebin | Returns metadata for all pastes (ID, language, creation date, size) without the actual content |
DELETE /api/pastes/:id |
Pastebin | Admin delete, bypasses the per-paste delete token |
GET /api/shortlinks/list |
Shortlinks | Returns all aliases with their target URLs and creation dates |
DELETE /api/shortlinks/:alias |
Shortlinks | Removes a shortlink by alias (this endpoint didn't exist before) |
All four require the API key. The paste list deliberately omits content, some pastes could be large, and the admin panel just needs to know what exists.
The Data Migration
This was the one part that could have gone sideways. The shortlinks service originally stored data as a flat map: {"gh": "https://github.com/..."}. We needed creation timestamps for the admin panel, so we changed it to an object: {"gh": {"url": "https://github.com/...", "createdAt": "2026-02-11T..."}}.
Rather than a separate migration script, we built automatic migration into the server startup:
const loadShortlinks = () => {
const raw = fs.readFileSync(DATA_FILE, 'utf8');
const data = JSON.parse(raw);
let migrated = false;
for (const [alias, value] of Object.entries(data)) {
if (typeof value === 'string') {
data[alias] = { url: value, createdAt: null };
migrated = true;
}
}
shortlinks = data;
if (migrated) {
saveShortlinks();
console.log('Migrated shortlinks to new format');
}
};
If the value is a string, it's old format, wrap it in the new object with createdAt: null. We don't fake timestamps for legacy links. Runs once on startup, handles any mix of old and new entries. When we deployed, the log showed the migration message followed by the loaded count. One existing shortlink, instantly migrated. Zero downtime.
The Admin Panel
We built a single static HTML page that talks to both services. Bootstrap 5, tabbed layout, one tab for pastes, one for shortlinks. Each tab has a data table and a create form.
The page stores your API key in sessionStorage (not localStorage, it clears when you close the tab, which felt like the right trade-off for a personal admin page). Enter the key once, every request includes it as the X-API-Key header.
Delete buttons on every row. Create forms at the bottom. Nothing fancy, but it beats SSH-ing in to cat a JSON file.
We also updated both public-facing frontends with an optional API key field, so you can create pastes and shortlinks from the existing UIs without needing the admin panel.
611 lines added, 7 files. About 40 minutes.
The Numbers
| Phase | Time | Lines | Files |
|---|---|---|---|
| Pastebin from scratch | ~20 min | 890 | 4 |
| Security hardening (both services) | ~20 min | 212 | 3 |
| API auth + admin panel | ~40 min | 611 | 7 |
| Total | ~80 min | ~1,700 | 14 |
All deployed and tested on production. Both services restarted, all endpoints verified.
How the Session Actually Worked
Each phase started the same way: I described what I wanted, Claude read the relevant source files in parallel, and then we built.
For Phase 1, Claude read the existing shortlinks server to understand the conventions (the http module patterns, the JSON storage approach, the atomic write strategy) and then wrote the entire pastebin server in the same style. Four files in one parallel batch.
For Phase 2, Claude read both server files in parallel, identified the shared issues, and applied fixes to both simultaneously. The rate limiter code is nearly identical between services because they share the same architecture.
For Phase 3, the pattern was the same but bigger: read all existing files, then write all 7 changed files in a single parallel batch. Both server files as complete rewrites (the auth changes touched too many locations for incremental edits), the new admin page, the updated frontends, and the updated service configurations.
After each phase: git add, commit, push, deploy via SCP, restart services via SSH, verify with curl. The curl tests after Phase 3 tested every auth scenario: no key, wrong key, right key, public reads, admin list, admin delete. All passed on the first deploy.
The Agent Pipeline (Yes, This Post Is Also Part of It)
The implementation itself used no sub-agents, it was all direct tool calls from the main Claude Code session, with parallelism coming from batching independent operations in single messages.
But after the coding was done, I asked Claude to do a few more things:
- Check the nginx config: Claude SSH'd into the server, confirmed all new API paths were already covered by the existing prefix-based proxy rules. No nginx changes needed for any of the three phases.
- Generate a Q&A PDF: Claude wrote a Python script to generate a reference PDF with the questions I asked during the session, plus questions I should have asked. Saved to my Desktop.
- Write this blog post: Delegated to the
smolkin-site-orchestratoragent, which coordinates content, sitemap updates, blog index updates, and git operations. - Add a Chart.js visualization to a different blog post (the APP vs APPX volatility decay analysis).
The full pipeline for this session: build pastebin → harden both services → add auth + admin panel → deploy and verify → check nginx → generate PDF → write and publish blog post → update another post. Each step used the right tool for the job.
What I Learned
This was a good session for noticing patterns in how AI-assisted development works when it's going well:
- I described goals, not implementations. "Build a pastebin that matches the shortlinks architecture" rather than "write a server that listens on port X with these routes." Claude figured out the structure. When it made choices I disagreed with, I said so and we adjusted.
- The security hardening was Claude's initiative. After building the pastebin, Claude flagged the shared issues: the RNG bias, the missing security headers, the lack of rate limiting. I would have gotten to some of these eventually, but not in 20 minutes.
- The migration-on-startup pattern was a joint decision. I mentioned needing timestamps, Claude proposed the schema change, and we agreed that automatic migration on load was cleaner than a separate script.
- Testing happened in the conversation. After each deployment, we ran curl commands against every endpoint, checked responses, and fixed issues on the spot. The feedback loop was tight.
It's not magic. It's more like pair programming where your partner types really fast and knows every Node.js API by heart, but sometimes needs you to say "no, simpler than that."
The Stack
| Component | Technology |
|---|---|
| Servers | Raw Node.js (http module, no framework) |
| Storage | JSON files on disk, atomic writes via temp file + rename |
| Auth | API key via X-API-Key header, crypto.timingSafeEqual |
| Security | Rate limiting, security headers, input validation, crypto.randomInt() |
| Admin UI | Static HTML, Bootstrap 5, vanilla JS |
| Proxy | nginx |
| CDN | Cloudflare |
| Process management | systemd |
| Co-pilot | Claude Code (Opus 4.6) |
No database. No ORM. No container. No CI/CD pipeline. Just files on a server, a couple of systemd units, and one afternoon with a good pair programming partner.
Update: April 2026: The Auth Gap
About two months after this session, I noticed a problem with the auth model described above. The two-tier API key system (admin key for full access, CREATE_API_KEY for public paste/shortlink creation) had a gap: the list endpoints (GET /api/pastes/list and GET /api/shortlinks/list) accepted any valid API key, not just the admin key. Since the CREATE_API_KEY is handed out to anyone who signs up through the pastebin, any signed-up user could enumerate every shortlink and every unexpired paste.
The comment in the code even said "admin only," but the check only verified the key was valid, not that it was the admin key. The fix was one line per service:
if (auth.role !== 'admin') return sendJson(res, 403, { error: 'Admin access required' });
But the API-level fix wasn't the whole story. The admin panel at /admin/ was a static HTML page with no server-side auth at all. The only gate was the client-side API key prompt. And /short redirected straight to /admin/#shortlinks via nginx, so anyone looking for the shortlink creator landed on the admin panel.
The full fix: add nginx Basic Auth on the /admin/ path, and redirect /short to the public /shortlinks/index.html creation page instead. The admin panel now requires a username and password before the page even loads, and the public creation UIs remain open for anyone with the create key.
Two months of "it works, ship it" before bothering to fix it. I noticed it earlier, but never took the time to fix it. Worth noting as a reminder that building fast doesn't mean the first pass catches everything.
Enjoyed this post?
Get notified when I publish something new. No spam, unsubscribe anytime.