{"slug": "i-gave-claude-an-api-to-post-to-my-dev-blog-and-the-bcrypt-hash-broke-in-2", "title": "I gave Claude an API to post to my dev blog (and the bcrypt hash broke in 2 different places)", "summary": "A developer built an API endpoint for their personal blog that allows Claude to publish posts via curl commands, but encountered two bcrypt hash parsing bugs in Next.js. The `@next/env` loader performed variable expansion on `$` characters within bcrypt hashes and ignored single-quote escaping, causing the hash to collapse into empty strings. The developer resolved the issue by either escaping `$` characters with backslashes or bypassing bcrypt entirely with a raw API key and constant-time string comparison.", "body_md": "It started with a side project: a personal blog. I wanted somewhere to write that wasn't a hosted platform, with the dark-mode-by-default and the URL structure and the editor I'd been craving.\n\nThen halfway through building it, a thought:\n\nWhat if I could just tell Claude\n\n\"publish a post about X\"and it actually shows up on my blog?\n\n48 hours later, here we are.\n\nThere are two ways to write on the blog:\n\n`POST /api/ai/posts`\n\nwith an `X-API-Key`\n\nheaderPath #2 is the new thing. The key idea: every Claude Code session I run from the terminal already has the API key in my env. So I can just do:\n\n```\ncurl -X POST http://localhost:3210/api/ai/posts \\\n  -H \"X-API-Key: $BLOG_AI_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"title\":\"...\",\"body_markdown\":\"...\",\"status\":\"draft\"}'\n```\n\nAnd a draft (or published post) appears on the blog. No browser. No copy-paste.\n\n```\nNext.js 16 (App Router) + React 19 + TypeScript\nTailwind v4 (dark-first)\nFile-based JSON store (will swap to Postgres later)\nBcrypt for the API key\nZod for input validation\n```\n\nI'm intentionally not using a database yet. A `.data/posts.json`\n\nfile works fine while I'm the only writer. Trading 30 lines of complexity for *\"I'll migrate when it matters.\"*\n\nThe handler lives at `app/api/ai/posts/route.ts`\n\n. It exports four functions for four verbs:\n\n```\nexport async function GET(req)    { /* list */ }\nexport async function POST(req)   { /* create */ }\nexport async function PUT(req)    { /* update */ }\nexport async function DELETE(req) { /* delete */ }\n```\n\nThe shared first step in each is auth:\n\n```\nasync function requireAuth(req: NextRequest) {\n  if (!apiKeyConfigured()) {\n    return jsonError(503, \"API key not configured\");\n  }\n  const ok = await verifyApiKey(req.headers.get(\"x-api-key\"));\n  if (!ok) return jsonError(401, \"Invalid or missing X-API-Key\");\n  return null;\n}\n```\n\nThen the verb-specific logic does the obvious thing — parse JSON, validate with zod, call the store, return JSON back.\n\nThe clever bit is in the store: when Claude POSTs a new post, the slug is auto-generated from the title. If that slug is taken, it appends `-2`\n\n, `-3`\n\n, etc. So Claude can call POST repeatedly with similar titles without worrying about collisions.\n\nThis took me 30 minutes to figure out and I want it documented somewhere so I never fall for it again.\n\nI generated an API key, hashed it with `bcryptjs`\n\n, dropped both into `.env.local`\n\n:\n\n```\nBLOG_AI_API_KEY_HASH=\"$2b$10$yNATBgF...\"\n```\n\nRestarted the server. Called the endpoint. Got `503: API key not configured`\n\n.\n\nThat's the error I throw when `process.env.BLOG_AI_API_KEY_HASH`\n\nis undefined.\n\nBut the env file has it. What gives?\n\nTurns out Next.js's `@next/env`\n\nloader does **variable expansion** inside double-quoted values. Bcrypt hashes start with `$2b$10$`\n\nand contain more `$`\n\ncharacters. The loader sees `$2b`\n\n, `$10`\n\n— those look like variable references to it. They don't exist as actual env vars, so they expand to empty strings, and your hash collapses to garbage.\n\nI tried single quotes:\n\n```\nBLOG_AI_API_KEY_HASH='$2b$10$yNATBgF...'\n```\n\nSame problem. Next.js's loader doesn't respect single-quoting the same way standard dotenv does — that's the second gotcha. In a vanilla `dotenv.config()`\n\ncall, single quotes prevent expansion. In Next.js they don't.\n\nTwo fixes that actually work:\n\n`BLOG_AI_API_KEY_HASH=\"\\$2b\\$10\\$...\"`\n\n`BLOG_AI_API_KEY`\n\n, do a constant-time string compare. The `.env.local`\n\nis gitignored anyway.I went with option 2 locally and option 1 (via Vercel's env-var UI, which doesn't interpret `$`\n\n) in production. The verifier is bilingual:\n\n```\nexport async function verifyApiKey(rawKey: string | null) {\n  if (!rawKey) return false;\n  const hash = process.env.BLOG_AI_API_KEY_HASH;\n  if (hash) return bcrypt.compare(rawKey, hash);\n  const raw = process.env.BLOG_AI_API_KEY;\n  if (raw) return timingSafeEqual(rawKey, raw);\n  return false;\n}\n```\n\nPrefers the hash. Falls back to raw. Works everywhere.\n\nHonestly, the most common use case so far is during the writing loop itself. I'll be in the terminal with Claude Code, talking through an idea. When it's good, I ask Claude to draft it and POST it as a draft. Then I open the admin in my browser and finish it in the split-view editor.\n\nThat's the whole thing: AI handles the rough cut, I handle the edit. Same as code review, but for prose.\n\nThe other surprisingly nice flow is updates. Halfway through writing a post I'll realize I want a fourth section. I describe it. Claude PUTs the update. I refresh the editor — there it is. The AI is operating *inside* my draft, not next to it.\n\n`canonical_url`\n\npointing back to my blog. SEO win.`/api/ai/profile`\n\nendpointThe architecture is uncomplicated enough to lift in an afternoon:\n\nIf you build something similar — or if you've found a saner way to handle bcrypt hashes in `.env`\n\n— I want to hear about it. The comments are open.", "url": "https://wpnews.pro/news/i-gave-claude-an-api-to-post-to-my-dev-blog-and-the-bcrypt-hash-broke-in-2", "canonical_source": "https://dev.to/sensational5510/i-gave-claude-an-api-to-post-to-my-dev-blog-and-the-bcrypt-hash-broke-in-2-different-places-18mo", "published_at": "2026-06-05 10:05:14+00:00", "updated_at": "2026-06-05 10:42:08.197785+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "artificial-intelligence", "large-language-models", "ai-agents"], "entities": ["Claude", "Next.js", "React", "TypeScript", "Tailwind", "Bcrypt", "Zod", "Postgres"], "alternates": {"html": "https://wpnews.pro/news/i-gave-claude-an-api-to-post-to-my-dev-blog-and-the-bcrypt-hash-broke-in-2", "markdown": "https://wpnews.pro/news/i-gave-claude-an-api-to-post-to-my-dev-blog-and-the-bcrypt-hash-broke-in-2.md", "text": "https://wpnews.pro/news/i-gave-claude-an-api-to-post-to-my-dev-blog-and-the-bcrypt-hash-broke-in-2.txt", "jsonld": "https://wpnews.pro/news/i-gave-claude-an-api-to-post-to-my-dev-blog-and-the-bcrypt-hash-broke-in-2.jsonld"}}