<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hboon.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hboon.com/" rel="alternate" type="text/html" /><updated>2026-07-12T09:27:50+00:00</updated><id>https://hboon.com/feed.xml</id><title type="html">Hwee-Boon Yar</title><subtitle>I write, ship and sell software products. Indie. Writing code in Swift, TypeScript and Ruby. Based in Singapore, working remotely. This is my blog.</subtitle><entry><title type="html">How Kamal Fits My Coding-Agent Workflow</title><link href="https://hboon.com/how-kamal-fits-my-coding-agent-workflow/" rel="alternate" type="text/html" title="How Kamal Fits My Coding-Agent Workflow" /><published>2026-07-05T06:12:00+00:00</published><updated>2026-07-05T06:12:00+00:00</updated><id>https://hboon.com/how-kamal-fits-my-coding-agent-workflow</id><content type="html" xml:base="https://hboon.com/how-kamal-fits-my-coding-agent-workflow/"><![CDATA[<p>I run a bunch of small products, and most of the work now happens through coding agents. That changes what I want from deployment.</p>

<p>Clicking around in a deployment platform is the wrong interface for that. I want deployment to be visible in the repo, scriptable from a terminal, and predictable when several agents are working at once.</p>

<p>For my current setup, Kamal is enough.</p>

<h2 id="the-setup-is-one-physical-server">The setup is one physical server</h2>

<p>My current pattern is simple: one Hetzner server, Docker containers, Kamal configs in each repo, and PostgreSQL on the same box.</p>

<p>The setup is intentionally small: one solo operator running small products that need to stay cheap and understandable.</p>

<p>The repo evidence is plain:</p>

<ul>
  <li><a href="https://myog.social/">MyOG.social</a> deploys <code class="language-plaintext highlighter-rouge">web</code>, <code class="language-plaintext highlighter-rouge">backend</code>, and <code class="language-plaintext highlighter-rouge">api</code> roles to the same host.</li>
  <li><a href="https://theblue.social/">TheBlue.social</a> deploys <code class="language-plaintext highlighter-rouge">web</code>, <code class="language-plaintext highlighter-rouge">backend</code>, <code class="language-plaintext highlighter-rouge">feed</code>, and worker roles to the same host.</li>
  <li>TheBlue’s <code class="language-plaintext highlighter-rouge">AGENTS.md</code> says it migrated from Render to Hetzner with Kamal 2 on April 8, 2026.</li>
  <li>TheBlue shares that server and the PostgreSQL accessory with MyOG.social and AltCaption.com.</li>
  <li>MyOG’s Kamal config defines the shared PostgreSQL accessory and the Vector logging accessory.</li>
</ul>

<p>I don’t set up a new database server for every tiny product. MyOG was the first project in this setup, so it owns the shared server pieces. The newer projects follow the pattern instead of inventing their own.</p>

<h2 id="myog-was-the-first-one">MyOG was the first one</h2>

<p>MyOG.social has the older Kamal setup in my repos. Its history shows the move in December 2025:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2025-12-20 Add Kamal 2 deployment configuration for Hetzner with PostgreSQL, Vector logging, SSL, and pre-rendering support
2025-12-19 Remove legacy render.yaml now using Kamal on Hetzner
</code></pre></div></div>

<p>The config is what I like about Kamal. The deployment state is a file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">service</span><span class="pi">:</span> <span class="s">myog</span>

<span class="na">servers</span><span class="pi">:</span>
  <span class="na">web</span><span class="pi">:</span>
    <span class="na">hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">&lt;server-ip&gt;</span><span class="pi">:</span> <span class="s">frontend</span>

  <span class="na">backend</span><span class="pi">:</span>
    <span class="na">hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">&lt;server-ip&gt;</span><span class="pi">:</span> <span class="s">backend</span>
    <span class="na">proxy</span><span class="pi">:</span>
      <span class="na">ssl</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">host</span><span class="pi">:</span> <span class="s">backend.myog.social</span>
      <span class="na">app_port</span><span class="pi">:</span> <span class="m">3001</span>

  <span class="na">api</span><span class="pi">:</span>
    <span class="na">hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">&lt;server-ip&gt;</span><span class="pi">:</span> <span class="s">api</span>
    <span class="na">proxy</span><span class="pi">:</span>
      <span class="na">ssl</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">host</span><span class="pi">:</span> <span class="s">api.myog.social</span>
      <span class="na">app_port</span><span class="pi">:</span> <span class="m">3002</span>

<span class="na">accessories</span><span class="pi">:</span>
  <span class="na">postgres</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:16-alpine</span>
    <span class="na">host</span><span class="pi">:</span> <span class="s">&lt;server-ip&gt;</span>

  <span class="na">vector</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">timberio/vector:latest-alpine</span>
    <span class="na">host</span><span class="pi">:</span> <span class="s">&lt;server-ip&gt;</span>
</code></pre></div></div>

<p>I trimmed that snippet, but the important parts are there: roles, host, proxy, app ports, accessories.</p>

<p>Kamal’s own docs describe the same model: roles live under <code class="language-plaintext highlighter-rouge">servers</code>, accessories are managed separately from the app, and the proxy routes traffic to the new container after the health check passes.</p>

<p>That maps well to how I want agents to work. If a deploy fails, the agent can read <code class="language-plaintext highlighter-rouge">config/deploy.yml</code>, compare it with the Dockerfile and runtime scripts, inspect logs, and make a repo change.</p>

<p>No special dashboard integration needed.</p>

<h2 id="theblue-migrated-later">TheBlue Migrated Later</h2>

<p>TheBlue.social moved later, after the MyOG setup had already proved itself.</p>

<p>Its <code class="language-plaintext highlighter-rouge">AGENTS.md</code> records the date directly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Migrated from Render to Hetzner (Kamal 2) on 2026-04-08.
Shares the Hetzner server and Postgres accessory
with MyOG.social and AltCaption.com.
</code></pre></div></div>

<p>TheBlue is also a good example of the pattern growing without turning into a platform.</p>

<p>It started as a web/backend app, then picked up more moving parts: feed service, background workers, and a lot of pre-rendered SEO pages. The Kamal setup split with it:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>config/deploy.yml          # web
config/deploy.backend.yml  # backend + workers
config/deploy.feed.yml     # custom feed service
</code></pre></div></div>

<p>The web deploy has Caddy serving the built frontend and host-mounted pre-rendered HTML releases. Backend deployment covers the API and workers. Feed has its own image and service.</p>

<p>Still the same idea: Docker images, Kamal configs, one server, explicit roles.</p>

<p>The deploy script detects changed areas and deploys the right targets:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scripts/push-and-deploy.sh
scripts/push-and-deploy.sh <span class="nt">--force</span>
kamal deploy <span class="nt">--roles</span><span class="o">=</span>web
kamal deploy <span class="nt">-c</span> config/deploy.backend.yml
kamal deploy <span class="nt">-c</span> config/deploy.feed.yml
</code></pre></div></div>

<p>It also handles a practical rule I care about: push to GitHub after deploy succeeds. If production deploy fails, <code class="language-plaintext highlighter-rouge">origin/main</code> should not advance as if everything shipped.</p>

<p>That rule matters more once agents are allowed to deploy. If production deploy fails, I want the repo state to say that clearly too.</p>

<h2 id="why-this-works-with-coding-agents">Why this works with coding agents</h2>

<p>Coding agents are good at text, diffs, commands, and logs. Kamal gives them exactly that.</p>

<p>A deployment problem usually becomes one of these:</p>

<ul>
  <li>a missing secret name in <code class="language-plaintext highlighter-rouge">.kamal/secrets</code> or <code class="language-plaintext highlighter-rouge">config/deploy.yml</code></li>
  <li>a bad health check path</li>
  <li>a Docker build issue</li>
  <li>a runtime script that starts the wrong process</li>
  <li>a frontend build-time env var that was treated like a runtime env var</li>
  <li>a role split that needs a different deploy config</li>
</ul>

<p>An agent can inspect those repo problems.</p>

<p>My project <code class="language-plaintext highlighter-rouge">AGENTS.md</code> files tell agents how to deploy, which package manager to use, how long web builds usually take, what not to print, and when to run the deploy in tmux with a callback.</p>

<p>That last part is important because deploys are long-running enough to be annoying inside a chat turn. The TheBlue instructions say web deploys have taken about 6.7 minutes, and that Vite can sit quietly at <code class="language-plaintext highlighter-rouge">rendering chunks...</code> for several minutes. The point of writing that down is to stop an agent from killing a deploy just because nothing printed for a while.</p>

<p>I want the agent to know that before it touches the system.</p>

<h2 id="the-deployment-lock-handles-the-shared-server">The deployment lock handles the shared server</h2>

<p>One server is simple, but RAM is finite.</p>

<p>If I have three coding agents working on three products, I do not want all three running Kamal deploys against the same server at the same time.</p>

<p>Agent Control handles that with a shared deploy lock:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>deploy:hetzner-main
</code></pre></div></div>

<p>The rule is: acquire the lock before starting a Hetzner/Kamal deploy, keep it through verification, then release it. If another session already has it, the new deploy waits.</p>

<p>The actual workflow is still the normal project deploy. The lock wraps it.</p>

<p>Each repo still owns its deploy command. Agent Control serializes access to the shared server.</p>

<p>So the flow becomes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent finishes change
agent runs checks
agent commits
agent asks for deploy lock
Agent Control queues it if another deploy is running
agent deploys in tmux when lock is acquired
agent verifies production
agent releases lock
agent reports back
</code></pre></div></div>

<p>This is the part that makes one server work with agent-driven development. The server stays simple, but the deployment jobs are serialized.</p>

<h2 id="where-i-draw-the-line">Where I Draw the Line</h2>

<p>One server has limits.</p>

<p>If the server is down, multiple products are affected. More capacity means moving pieces around. A product that grows a lot may need to leave the shared setup.</p>

<p>That is fine for where these products are now.</p>

<p>Most small products do not need a platform on day one. They need backups, logs, health checks, repeatable deploys, and a setup the owner understands. My setup has those pieces without making me operate a deployment platform.</p>

<p>Kamal is not magic. It still uses Docker, SSH, a registry, env files, health checks, and a proxy. The useful part is that those pieces are explicit and close to the code.</p>

<p>That fits my work better than a sprawling platform.</p>

<h2 id="what-i-keep-reusing">What I Keep Reusing</h2>

<p>The pattern I keep copying into newer projects:</p>

<ul>
  <li>one Kamal config per deployable boundary when the app grows</li>
  <li><code class="language-plaintext highlighter-rouge">web</code>, <code class="language-plaintext highlighter-rouge">backend</code>, <code class="language-plaintext highlighter-rouge">feed</code>, or worker roles instead of one giant container</li>
  <li><code class="language-plaintext highlighter-rouge">.kamal/secrets</code> reading from <code class="language-plaintext highlighter-rouge">.env.kamal</code>, without committing values</li>
  <li>app-specific <code class="language-plaintext highlighter-rouge">AGENTS.md</code> deploy notes</li>
  <li>push/deploy scripts that detect changed services</li>
  <li>tmux for deploys that take minutes</li>
  <li>Agent Control callbacks after long-running commands</li>
  <li><code class="language-plaintext highlighter-rouge">deploy:hetzner-main</code> for shared-server deploy serialization</li>
</ul>

<p>For my current small-products life, this is enough infrastructure.</p>

<p>I can tell a coding agent to fix something, run the checks, deploy it, verify it, and report back. The deploy path is plain text the agent can read and reason about.</p>]]></content><author><name></name></author><category term="Web" /><category term="Tools" /><category term="AI" /><summary type="html"><![CDATA[Why I run small products on one Hetzner server with Kamal, and how Agent Control serializes deployments when coding agents are doing the work.]]></summary></entry><entry><title type="html">Why I Built an Orchestration Tool for My Coding Agents</title><link href="https://hboon.com/why-i-built-an-orchestration-tool-for-my-coding-agents/" rel="alternate" type="text/html" title="Why I Built an Orchestration Tool for My Coding Agents" /><published>2026-07-04T05:41:00+00:00</published><updated>2026-07-04T05:41:00+00:00</updated><id>https://hboon.com/why-i-built-an-orchestration-tool-for-my-coding-agents</id><content type="html" xml:base="https://hboon.com/why-i-built-an-orchestration-tool-for-my-coding-agents/"><![CDATA[<p>I run a lot of coding-agent sessions now.</p>

<p>One agent fixing a bug is easy. Five agents across different projects, with deploys, SEO checks, product loops, callbacks, and follow-up work, is a different problem. Once I run enough agents, I spend more time tracking sessions, queues, callbacks, and follow-ups than asking whether the agent can write code.</p>

<p>I built Agent Control for that layer of the work.</p>

<p>Agent Control is my in-house macOS app and CLI for coordinating coding-agent sessions. It does not replace Claude Code, Codex, or Droid. It sits around them and handles the parts I kept tracking by hand once coding agents became part of the daily workflow: queues, callbacks, loops, handoffs, locks, session history, and cross-project messages.</p>

<p>I am a bootstrapped indie developer, and I choose to work across many small projects at the same time. Part of that is because I use SEO as an inbound channel. SEO takes time. A project may need months before I know whether the idea, keywords, product, and market are worth more effort.</p>

<p>That pushes me toward a workflow with many small active bets, not one large project that gets all my time.</p>

<h2 id="i-wanted-prompts-to-become-work-not-notes">I wanted prompts to become work, not notes</h2>

<p>I have a loose rule now: write prompts instead of TODOs that never get worked on.</p>

<p>A TODO in a notes file is a reminder. It may get done. It may sit there for six months. A prompt in Agent Control can become a queued session, a child worker, or the next loop run.</p>

<p>That matters because a lot of agent work starts as a half-formed thought:</p>

<ul>
  <li>check whether this page is still ranking</li>
  <li>compare yesterday’s analytics with the last 7 days</li>
  <li>follow up on that deploy after GitHub Pages finishes</li>
  <li>review this repo when the current session exits</li>
  <li>run this SEO loop again tomorrow</li>
</ul>

<p>Not all of that should run immediately. A few prompts should wait behind the active same-repo session. Others should run as fresh child sessions, resume an existing coordinator, or run later.</p>

<p>Before Agent Control, this became a mix of tmux windows, notes, shell history, Reminders.app links, and me remembering which agent was doing what. That worked for a while. Then I had enough loops and projects that the bookkeeping became its own job.</p>

<h2 id="i-prefer-serialized-work-on-one-repo">I prefer serialized work on one repo</h2>

<p>I usually work on my own projects on the main branch. I do not reach for git worktrees unless I have a specific reason.</p>

<p>Partly taste, partly risk management. Most of my projects are small enough that serialized work is simpler. I would rather have one agent finish, review, commit, deploy, and archive before another agent starts changing the same repo.</p>

<p>Agent Control follows that preference. If a repo already has an active session, same-repo work queues behind it. A queued task can still be explicit about what it wants:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-control-cli add <span class="se">\</span>
  <span class="nt">--repo</span> ~/p/theblue/theblue.social <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Check the onboarding funnel and suggest one low-risk improvement."</span>
</code></pre></div></div>

<p>That prompt is queued.</p>

<p>There are exceptions. Parent loop sessions can spawn child sessions. Read-only investigations can force-start. But the default is conservative: one repo, one active lane.</p>

<p>The queue also depends on sessions exiting when they are done.</p>

<p>I have an <code class="language-plaintext highlighter-rouge">ask-to-exit</code> skill for that. A worker can finish the job, report back, then ask to close itself. Once Agent Control sees that session exit and archives it, the app knows the same-repo lane is clear and can start the next queued task.</p>

<p>Serial queueing works better when the finished agent can give Agent Control a clear “this session is done” signal.</p>

<h2 id="i-want-the-process-to-be-agentic">I want the process to be agentic</h2>

<p>I do not want an agent to only answer a prompt.</p>

<p>I want it to run the command, read the output, make the next decision, and report back to the right place. If a deploy takes a few minutes, I do not want to babysit the terminal. I want the deploy to run in tmux and send a callback to the exact Agent Control session that started it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-control-cli message <span class="se">\</span>
  <span class="nt">--session</span> <span class="s2">"</span><span class="nv">$AGENT_CONTROL_SESSION_ID</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Deploy finished. Verify and report status."</span>
</code></pre></div></div>

<p>That small callback changed a lot of my workflow.</p>

<p>Without it, a long-running command finishes somewhere. Maybe I notice. Maybe I do not. With it, the agent gets pulled back into the work at the right time. It checks the result, fixes errors if needed, and tells me what happened.</p>

<p>Agent Control has a CLI for the same reason. The app is useful, but the CLI lets agents, scripts, tmux panes, and loop workers coordinate without me clicking around.</p>

<h2 id="agents-need-to-talk-across-projects">Agents need to talk across projects</h2>

<p>A lot of my work crosses repo boundaries now.</p>

<p>A product loop may inspect analytics for one app, update a landing page, queue a follow-up in the blog, and tell a coordinator session what happened. Deploys can finish in tmux and report back to the exact session that started them. Child workers can finish SEO checks and message the parent loop before they exit.</p>

<p>Plain tmux does not try to solve that. tmux keeps processes alive. Agent Control gives those processes an address.</p>

<p>An Agent Control session ID is not the same thing as a Claude Code, Codex, or Droid session ID. Agent Control owns its own session identity. That gives me a stable target for messages:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-control-cli message <span class="se">\</span>
  <span class="nt">--session</span> <span class="s2">"197B1443-06E7-40F6-BC6E-13AA3BC20A73"</span> <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"The child SEO check finished. Schedule the next run for tomorrow."</span>
</code></pre></div></div>

<p>That can come from another agent, a shell script, a tmux callback, or a loop worker. It does not matter which backing agent is underneath.</p>

<h2 id="what-carried-over-from-tmux">What carried over from tmux</h2>

<p>Most of my coding-agent workflow started in tmux.</p>

<p>I wrote about <a href="/using-tmux-with-claude-code/">using tmux with Claude Code</a> because tmux gave me the primitives I needed:</p>

<ul>
  <li>persistent sessions</li>
  <li>visible logs and process output</li>
  <li>keyboard-first navigation</li>
  <li>scrollback and search</li>
  <li>simple commands agents can run</li>
  <li>multiple agents in separate windows</li>
</ul>

<p>I kept all of that.</p>

<p>Agent Control is not a replacement for tmux. My long-running dev servers, deploys, and shell processes still run there. The difference is that tmux is process coordination, while Agent Control is agent-session coordination.</p>

<p>That includes deploys. If a command may take a few minutes, I still run it in tmux so the output stays visible and the process survives the agent session. Agent Control handles the callback when the command finishes.</p>

<p>tmux tells me where the process is.</p>

<p>Agent Control answers:</p>

<ul>
  <li>which agent session owns this task?</li>
  <li>what is queued for this repo?</li>
  <li>which session needs attention?</li>
  <li>where should this callback go?</li>
  <li>what loop should run next?</li>
  <li>what did the last worker report?</li>
</ul>

<p>This works well for me. tmux stays good at being tmux. Agent Control handles the layer above it.</p>

<h2 id="what-agent-control-does">What Agent Control does</h2>

<p>Agent Control is a SwiftUI macOS app with a SQLite database behind it. The CLI writes into the same database and notifies the app.</p>

<p>The app manages sessions for:</p>

<ul>
  <li>Claude Code</li>
  <li>Codex</li>
  <li>Droid</li>
</ul>

<p>Each session has a repo path, agent type, status, transcript, archive state, queue state, and enough metadata for the app and CLI to find it again later.</p>

<p>The main things I use every day:</p>

<ul>
  <li>start or queue a fresh coding-agent session</li>
  <li>message an existing session by ID</li>
  <li>queue work behind the active same-repo session</li>
  <li>search old sessions and messages</li>
  <li>create bookmarks into relevant parts of session history</li>
  <li>run callbacks from tmux and scripts</li>
  <li>create child sessions from a parent coordinator</li>
  <li>schedule recurring loop prompts</li>
  <li>let finished sessions ask to close themselves</li>
  <li>use locks for shared resources like deploys or browser control</li>
</ul>

<p>The UI is mostly there so I can see what is going on. The CLI is there so agent sessions running inside the Agent Control app can do the coordination themselves.</p>

<p>Session history matters more than I expected. When a session finishes something worth remembering, I can have it create a bookmark URL for the relevant point in the transcript and append that URL to my task log. Later, when I see the task in <code class="language-plaintext highlighter-rouge">wip.md</code>, I can jump back to the exact session history instead of searching through dozens of old agent conversations.</p>

<h2 id="the-cli-primitives-matter-more-than-the-ui">The CLI primitives matter more than the UI</h2>

<p>The CLI is intentionally small. I use it myself sometimes, but the main caller is usually an agent session already running inside the Agent Control macOS app.</p>

<p>Start a new task:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-control-cli add <span class="se">\</span>
  <span class="nt">--repo</span> ~/p/myog/myog.social <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Check today's Google Search Console changes and report anything worth acting on."</span>
</code></pre></div></div>

<p>Message an existing session:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-control-cli message <span class="se">\</span>
  <span class="nt">--session</span> <span class="s2">"SESSION_ID"</span> <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"The export finished. Verify the report and summarize the result."</span>
</code></pre></div></div>

<p>Queue a child session under a parent:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-control-cli add <span class="se">\</span>
  <span class="nt">--parent-session</span> <span class="s2">"</span><span class="nv">$AGENT_CONTROL_SESSION_ID</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"Run this loop iteration, then report back to the parent session."</span>
</code></pre></div></div>

<p>Schedule the next loop run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-control-cli loops schedule <span class="se">\</span>
  <span class="nt">--session</span> <span class="s2">"SESSION_ID"</span> <span class="se">\</span>
  <span class="nt">--loop</span> <span class="s2">"LOOP_ID"</span> <span class="se">\</span>
  <span class="nt">--title</span> <span class="s2">"SEO check"</span> <span class="se">\</span>
  <span class="nt">--next-run</span> <span class="s2">"2026-07-05T09:00:00+08:00"</span> <span class="se">\</span>
  <span class="nt">--cadence</span> <span class="s2">"tomorrow"</span> <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"</span><span class="nv">$next_prompt</span><span class="s2">"</span>
</code></pre></div></div>

<p>The commands are plain enough for the agent to run. I can put them in <code class="language-plaintext highlighter-rouge">AGENTS.md</code>. A tmux deploy can call them after a long-running command finishes.</p>

<h2 id="loops-need-a-coordinator">Loops need a coordinator</h2>

<p>The loop workflow is where Agent Control became much more useful than my older Reminders.app setup.</p>

<p>The pattern uses two sessions:</p>

<ul>
  <li>an outer coordinator session that owns the recurring loop</li>
  <li>a fresh inner worker session for one iteration</li>
</ul>

<p>The coordinator wakes up, creates a child worker, and waits. That child does the actual work, sends a structured callback to the coordinator, then exits or archives. After the callback arrives, the coordinator schedules the next run.</p>

<p>For example, an SEO loop may do this:</p>

<ul>
  <li>coordinator wakes up for MyOG.social</li>
  <li>child session checks GSC, Ahrefs, and analytics</li>
  <li>child makes a low-risk change only if the evidence supports it</li>
  <li>child runs the normal finish workflow: review, test, commit, deploy, verify</li>
  <li>child reports back with <code class="language-plaintext highlighter-rouge">agent-control-cli message</code></li>
  <li>coordinator schedules the next run with the latest context</li>
</ul>

<p>The long-lived loop context stays separate from the worker context. The coordinator does not need every log line from the child. It needs the result, evidence, changed files, commit, deploy status, blockers, and next action.</p>

<p>That one pattern cleaned up a lot of recurring work.</p>

<h2 id="where-agentsmd-and-skills-fit">Where AGENTS.md and skills fit</h2>

<p>Agent Control does not replace repo instructions or skills.</p>

<p>I still use <a href="/how-i-write-and-maintain-agents-md-for-my-coding-agents/">AGENTS.md</a> to tell agents how to work inside a repo: commands, deploy rules, coding conventions, browser-check rules, and project-specific traps.</p>

<p>I still use <a href="/coding-agent-skills-vs-slash-commands-mcp-and-cli-tools/">skills</a> for repeatable workflows: commit, deploy, take over, loop-task, external service reports, and so on.</p>

<p>Agent Control decides which session gets work and how sessions report back. <code class="language-plaintext highlighter-rouge">AGENTS.md</code> tells the agent how to do the work once it is in the repo. Skills package up workflows the agent should follow.</p>

<p>Those layers should stay separate. It’s easier to maintain.</p>

<h2 id="what-i-did-not-build">What I did not build</h2>

<p>Agent Control is not an IDE.</p>

<p>I did not add editing or git functionality to it. I already have Neovim and GitUp for those jobs. Agent Control has configurable commands to open the current file in my editor or the current repo in my git client, but it does not try to become either tool.</p>

<p>It is not a replacement for Claude Code, Codex, Droid, tmux, git, Kamal, DevSnoop, or <code class="language-plaintext highlighter-rouge">AGENTS.md</code>.</p>

<p>It does not make agents smarter, decide whether an SEO edit is good, remove review, or make five agents safely edit the same repo at once.</p>

<p>It mostly gives my agent work a place to go:</p>

<ul>
  <li>sessions have names and status</li>
  <li>queued prompts wait behind active work</li>
  <li>callbacks know where to return</li>
  <li>loops know which coordinator owns them</li>
  <li>child sessions can report to parents</li>
  <li>shared resources can be locked</li>
</ul>

<p>I do not need much more than that for my current workflow.</p>

<h2 id="why-i-built-it-in-house">Why I built it in-house</h2>

<p>I built it by using it.</p>

<p>I did not start with a product spec. I started with places where the workflow kept breaking down:</p>

<ul>
  <li>I lost track of which agent was doing what</li>
  <li>long-running deploys needed to call agents back</li>
  <li>loops needed a stable coordinator</li>
  <li>Reminders.app links were too clumsy for recurring agent work</li>
  <li>same-repo work needed to serialize by default</li>
  <li>agents needed to talk across projects</li>
</ul>

<p>I could have kept layering scripts on top of tmux. I still use plenty of scripts. But once session state, queue state, loop state, and callbacks all needed to agree, a small app with a database made more sense.</p>

<p>The full setup is still simple in the way I care about. Agent Control owns the state. The CLI exposes it. Agents use the CLI. tmux keeps the long-running processes visible.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><summary type="html"><![CDATA[Why I built Agent Control, my macOS app and CLI for coordinating coding-agent sessions, queues, callbacks, loops, and cross-project work.]]></summary></entry><entry><title type="html">Coding Agents Still Need Engineering Judgment</title><link href="https://hboon.com/coding-agents-still-need-engineering-judgment/" rel="alternate" type="text/html" title="Coding Agents Still Need Engineering Judgment" /><published>2026-06-28T05:08:00+00:00</published><updated>2026-06-28T05:08:00+00:00</updated><id>https://hboon.com/coding-agents-still-need-engineering-judgment</id><content type="html" xml:base="https://hboon.com/coding-agents-still-need-engineering-judgment/"><![CDATA[<p>I use coding agents every day. They write all my code now.</p>

<p>I do not think vibe-coded SaaS apps are automatically doomed. The problem is who is driving the agent.</p>

<h2 id="the-cleanup-usually-starts-across-the-system">The cleanup usually starts across the system</h2>

<p>The code can be bad even when each file looks plausible. The UI works, the demo looks real, and there are routes, components, migrations, and auth screens.</p>

<p>Then you look across the app:</p>

<ul>
  <li>The database model does not match the product rules.</li>
  <li>Auth exists, but ownership checks are missing in the places that matter.</li>
  <li>Background jobs, retries, rate limits, and idempotency were never designed.</li>
  <li>The architecture is accidental instead of chosen.</li>
  <li>The app has five partial patterns instead of one consistent one.</li>
</ul>

<p>AI is good at continuing a pattern. It is much worse when there is no pattern yet, or when the person driving it cannot tell whether the pattern is any good.</p>

<h2 id="how-i-keep-agents-from-making-that-mess">How I keep agents from making that mess</h2>

<p>My workflow is built around avoiding that. For larger features, I write a spec first: data model, API behavior, edge cases, and what not to change. I wrote about this in <a href="/two-ways-to-direct-coding-agents/">Two Ways to Direct Coding Agents</a>. For new projects, I often point the agent at one of my existing projects and tell it to build the same way, except for the parts I want changed.</p>

<p>Every repo I work on has an <a href="/how-i-write-and-maintain-agents-md-for-my-coding-agents/"><code class="language-plaintext highlighter-rouge">AGENTS.md</code></a> file: commands, architecture, conventions, deployment rules, and mistakes the agent has made before.</p>

<p>I also have a simple skill that uses a second agent to <a href="/using-a-second-llm-to-review-your-coding-agent-s-work/">review the first agent’s work</a>. One writes the change, another reviews the dirty diff, then the first one fixes what the reviewer caught.</p>

<h2 id="i-would-audit-before-refactoring">I would audit before refactoring</h2>

<p>I would not automatically throw away every AI-built app. Plenty are fine. Others need hardening. A few should be rebuilt because the foundation is wrong.</p>

<p>The first pass I would do on a vibe-coded SaaS app is not a refactor. It is an audit:</p>

<ul>
  <li>Can two users access each other’s data?</li>
  <li>Are permissions enforced on the backend, including server-owned fields like <code class="language-plaintext highlighter-rouge">role</code>, <code class="language-plaintext highlighter-rouge">plan</code>, or owner IDs?</li>
  <li>Does the database model match the business?</li>
  <li>Is the database reachable only by the app/backend?</li>
  <li>Are payment, webhook, subscription, and rate-limit states explicit?</li>
  <li>Are secrets kept out of frontend code and public env vars?</li>
  <li>Can the app be deployed from a clean checkout?</li>
  <li>Is the architecture clear enough that the next feature has an obvious place to go?</li>
  <li>Is there one pattern for routes, errors, jobs, and logging?</li>
</ul>

<p>When I use an agent for this, I avoid broad prompts like “is this app secure?” I ask it to check all API routes for authentication, authorization, ownership, server-owned fields, paid feature limits, rate limits, and secrets crossing into the frontend.</p>

<p>After that, the decision is clearer: patch, harden, or rebuild.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><summary type="html"><![CDATA[AI-built SaaS apps can work, but the expensive cleanup starts when nobody supervises the system design, data model, auth boundaries, and production workflows.]]></summary></entry><entry><title type="html">Cloudflare Tunnels Changed How I Test Local Integrations</title><link href="https://hboon.com/cloudflare-tunnels-changed-how-i-test-local-integrations/" rel="alternate" type="text/html" title="Cloudflare Tunnels Changed How I Test Local Integrations" /><published>2026-06-25T08:13:00+00:00</published><updated>2026-06-25T08:13:00+00:00</updated><id>https://hboon.com/cloudflare-tunnels-changed-how-i-test-local-integrations</id><content type="html" xml:base="https://hboon.com/cloudflare-tunnels-changed-how-i-test-local-integrations/"><![CDATA[<p>I used to treat local integration testing as a special mode. Run the app on <code class="language-plaintext highlighter-rouge">localhost</code>, add a forwarding URL when a provider needed to call back, update a dashboard, test the thing, then undo half of it later.</p>

<p>Cloudflare Tunnel changed that for me. My local apps now have stable HTTPS dev domains, so OAuth callbacks, webhooks, mobile callbacks, and browser testing go through the same public boundary every time.</p>

<h2 id="the-mental-model">The mental model</h2>

<p>Before tunnels, local testing looked like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>browser -&gt; http://localhost:5173
backend -&gt; http://localhost:4000
provider dashboard -&gt; random forwarding URL, if I remembered to update it
phone -&gt; maybe my LAN IP, maybe nothing
</code></pre></div></div>

<p>That works for testing screens. It is awkward for integrations because the other service is not on my laptop.</p>

<p>Now I try to make local development look like a small public deployment:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>browser
  -&gt; https://dev-example.com
  -&gt; Cloudflare Tunnel
  -&gt; http://localhost:5173

Stripe or GitHub
  -&gt; https://dev-example-backend.com/webhooks/provider
  -&gt; Cloudflare Tunnel
  -&gt; http://localhost:4000/webhooks/provider

phone
  -&gt; https://dev-example.com
  -&gt; Cloudflare Tunnel
  -&gt; http://localhost:5173
</code></pre></div></div>

<p>It is still local. The processes are still on my machine. But the URLs look like real URLs, with HTTPS, stable hostnames, and provider dashboards that do not need to change every time I restart a terminal.</p>

<h2 id="stable-hostnames-remove-a-lot-of-dashboard-work">Stable hostnames remove a lot of dashboard work</h2>

<p>Cloudflare has <a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/">Quick Tunnels</a> for development. You can run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cloudflared tunnel <span class="nt">--url</span> http://localhost:8080
</code></pre></div></div>

<p>It prints a random <code class="language-plaintext highlighter-rouge">trycloudflare.com</code> hostname and proxies requests back to your local server. Cloudflare describes quick tunnels as useful for testing and development. I would not put a production site behind one.</p>

<p>I like quick tunnels for one-off sharing. I do not like them for normal product development.</p>

<p>Provider dashboards, OAuth clients, and mobile apps all remember URLs. If the hostname changes, the test setup changes.</p>

<p>For my own projects I use a named tunnel with hostnames I control. The Cloudflare setup is a little more work once, then the same URLs keep working:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cloudflared tunnel create dev
cloudflared tunnel route dns dev dev-example.com
cloudflared tunnel route dns dev dev-example-backend.com
</code></pre></div></div>

<p>Cloudflare documents that a tunnel gets a UUID-backed <code class="language-plaintext highlighter-rouge">cfargotunnel.com</code> target, and DNS records point your hostname at that tunnel target. The CLI route command creates the CNAME for locally-managed tunnels.</p>

<p>After that, my provider dashboard can keep using:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://dev-example-backend.com/webhooks/stripe
https://dev-example-backend.com/oauth/google/callback
</code></pre></div></div>

<p>No random URL pasted into five places. No “which tunnel URL is current?” problem.</p>

<h2 id="the-config-becomes-the-test-map">The config becomes the test map</h2>

<p>My <code class="language-plaintext highlighter-rouge">~/.cloudflared/config.yml</code> is the map:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">tunnel</span><span class="pi">:</span> <span class="s">&lt;tunnel-id&gt;</span>
<span class="na">credentials-file</span><span class="pi">:</span> <span class="s">/Users/me/.cloudflared/&lt;tunnel-id&gt;.json</span>

<span class="na">ingress</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev-example.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:5173</span>
  <span class="pi">-</span> <span class="na">hostname</span><span class="pi">:</span> <span class="s">dev-example-backend.com</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">http://localhost:4000</span>
  <span class="pi">-</span> <span class="na">service</span><span class="pi">:</span> <span class="s">http_status:404</span>
</code></pre></div></div>

<p>Cloudflare’s <a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/">configuration docs</a> say ingress rules are matched from top to bottom, and configs with ingress rules need a final catch-all rule. I use <code class="language-plaintext highlighter-rouge">http_status:404</code> because unmatched hostnames should fail clearly.</p>

<p>This file answers the question I used to keep in my head:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>public hostname -&gt; local port
</code></pre></div></div>

<p>When an integration breaks, I start with that mapping. I do not start in the provider dashboard.</p>

<h2 id="i-prove-the-route-before-changing-provider-settings">I prove the route before changing provider settings</h2>

<p>When a callback or webhook fails, I check the path from outside in.</p>

<p>First, DNS:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dig +short dev-example-backend.com
</code></pre></div></div>

<p>Then HTTPS reachability:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> https://dev-example-backend.com/health
</code></pre></div></div>

<p>Then the actual route:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="se">\</span>
  <span class="nt">-X</span> POST <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{"ping":true}'</span> <span class="se">\</span>
  https://dev-example-backend.com/webhooks/provider
</code></pre></div></div>

<p>For webhook endpoints, I do not care if the fake request returns <code class="language-plaintext highlighter-rouge">400</code>, <code class="language-plaintext highlighter-rouge">401</code>, or signature verification failure. I care that the request reaches the local process and the log shows the route I expected.</p>

<p>Then I ask Cloudflare which ingress rule it would use:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cloudflared tunnel ingress rule https://dev-example-backend.com/webhooks/provider
</code></pre></div></div>

<p>And I validate the config:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cloudflared tunnel ingress validate
</code></pre></div></div>

<p>Those two commands are more useful than staring at dashboard settings. One tells me which rule matches the URL. The other catches config mistakes before I blame Stripe, GitHub, Google, or my app.</p>

<h2 id="the-app-sees-a-real-host">The app sees a real host</h2>

<p>This also changed how I debug app config.</p>

<p>Many local bugs only show up when the app uses a public origin:</p>

<ul>
  <li>OAuth redirect URLs must match the registered value. <a href="https://developers.google.com/identity/protocols/oauth2/web-server">Google’s OAuth docs</a> say the redirect URI must exactly match an authorized redirect URI, or you get <code class="language-plaintext highlighter-rouge">redirect_uri_mismatch</code>.</li>
  <li>Webhook providers send requests to an HTTPS endpoint you register. <a href="https://docs.stripe.com/webhooks">Stripe’s webhook docs</a> describe registering an HTTPS webhook endpoint that receives event data.</li>
  <li>Cookies, CORS, callback URLs, generated absolute URLs, and backend allowlists often depend on the request host.</li>
</ul>

<p>If I test everything on raw <code class="language-plaintext highlighter-rouge">localhost</code>, I can accidentally test a different app than the one I configured.</p>

<p>With tunneled dev domains, I can set local env vars to the same kind of values I use elsewhere:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">APP_URL</span><span class="o">=</span>https://dev-example.com
<span class="nv">API_URL</span><span class="o">=</span>https://dev-example-backend.com
<span class="nv">GOOGLE_REDIRECT_URI</span><span class="o">=</span>https://dev-example-backend.com/oauth/google/callback
<span class="nv">STRIPE_WEBHOOK_URL</span><span class="o">=</span>https://dev-example-backend.com/webhooks/stripe
</code></pre></div></div>

<p>The values are still development values. But they have the same scheme, hostname behavior, and callback path behavior as the deployed app.</p>

<p>That catches more useful bugs.</p>

<h2 id="once-i-pick-a-domain-i-use-it">Once I pick a domain, I use it</h2>

<p>I still use <code class="language-plaintext highlighter-rouge">localhost</code> as the tunnel target. I avoid using it as the app URL once I settle on a domain name.</p>

<p>The local process still runs on a port:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://localhost:5173
</code></pre></div></div>

<p>But I open the app through the tunnel subdomain:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://dev-example.com
</code></pre></div></div>

<p>I usually put the project domain name into the tunnel subdomain. If the product is <code class="language-plaintext highlighter-rouge">example.com</code>, the local URLs become something like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://dev-example.com
https://dev-example-backend.com
</code></pre></div></div>

<p>That keeps the browser, backend config, OAuth callback URLs, webhook endpoints, cookies, and mobile testing on the same dev origin. I do not have one path through <code class="language-plaintext highlighter-rouge">localhost</code> and another path through the domain.</p>

<p>My rule is:</p>

<ul>
  <li>before the project has a domain: local ports are fine</li>
  <li>after the tunnel subdomain exists: use the domain as the app URL</li>
  <li>provider calls my machine: use the tunnel domain</li>
  <li>phone or tablet testing: use the tunnel domain</li>
  <li>one-off share link: quick tunnel is fine</li>
</ul>

<p>The tunnel config still points to <code class="language-plaintext highlighter-rouge">localhost</code>. I just stop treating <code class="language-plaintext highlighter-rouge">localhost</code> as the URL I develop against.</p>

<h2 id="what-changed-in-practice">What changed in practice</h2>

<p>Before, an integration failure felt spread across too many places: local server, forwarding tool, provider dashboard, callback config, browser console, phone, and logs.</p>

<p>Now I start with the public route:</p>

<ul>
  <li>Does the hostname resolve?</li>
  <li>Does HTTPS work?</li>
  <li>Does Cloudflare route the hostname to the local port I expect?</li>
  <li>Does the app log the request?</li>
  <li>Does the provider dashboard use the stable dev URL?</li>
  <li>Is the app configured with the same dev origin?</li>
</ul>

<p>Only after that do I debug provider-specific behavior: signatures, event types, OAuth scopes, redirect URI registration, retries, and handler code.</p>

<p>I wrote the setup steps in <a href="/configuring-cloudflare-tunnel-to-expose-servers-for-local-development-webhooks-etc/">Configuring Cloudflare Tunnel to Expose Servers for Local Development, Webhooks etc</a>. Later I wrote about treating <a href="/my-cloudflare-tunnel-config-is-my-local-dev-directory/"><code class="language-plaintext highlighter-rouge">~/.cloudflared/config.yml</code> as my local dev directory</a>.</p>

<p>This is the reason behind both posts: stable HTTPS dev domains make local integrations feel less like a pile of exceptions.</p>]]></content><author><name></name></author><category term="Tools" /><category term="Web" /><summary type="html"><![CDATA[Cloudflare Tunnel changed local integration testing for me because it gave local services stable HTTPS names that behave like the real app boundary.]]></summary></entry><entry><title type="html">Why Webhooks Feel Hard In Local Development</title><link href="https://hboon.com/why-webhooks-feel-hard-in-local-development/" rel="alternate" type="text/html" title="Why Webhooks Feel Hard In Local Development" /><published>2026-06-25T07:37:00+00:00</published><updated>2026-06-25T07:37:00+00:00</updated><id>https://hboon.com/why-webhooks-feel-hard-in-local-development</id><content type="html" xml:base="https://hboon.com/why-webhooks-feel-hard-in-local-development/"><![CDATA[<p>When I test a normal feature locally, my browser talks to my backend on the same laptop.</p>

<p>Webhooks are different. Stripe or GitHub starts the request, and <code class="language-plaintext highlighter-rouge">localhost</code> means their machine, not mine. A localhost webhook URL will never reach my laptop from Stripe.</p>

<p>For local testing, I need a public URL that eventually reaches the process on my laptop.</p>

<h2 id="the-mental-model">The mental model</h2>

<p>A normal local request looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>browser on my laptop -&gt; http://localhost:3000/api/thing
</code></pre></div></div>

<p>A webhook delivery looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>provider's server -&gt; https://my-public-url.example.com/webhooks/provider
</code></pre></div></div>

<p>When I test webhooks locally, I want the provider’s server to hit a public HTTPS URL that forwards to my laptop:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>provider's server
  -&gt; https://dev-my-app.example.com/webhooks/provider
  -&gt; tunnel
  -&gt; http://localhost:3000/webhooks/provider
</code></pre></div></div>

<p>Without the public URL, the provider has nowhere to send the request.</p>

<p>So I start by checking everything before the handler:</p>

<ul>
  <li>the provider cannot reach the public URL</li>
  <li>the tunnel is down</li>
  <li>the tunnel points to the wrong local port</li>
  <li>the app route is wrong</li>
  <li>the route rejects <code class="language-plaintext highlighter-rouge">POST</code></li>
  <li>JSON parsing changed the raw body before signature verification</li>
  <li>the secret is from a different endpoint</li>
  <li>the handler returns too slowly</li>
  <li>the handler returns <code class="language-plaintext highlighter-rouge">200</code>, but your async work failed after that</li>
</ul>

<p>If any of those are wrong, I fix that before reading the handler code.</p>

<h2 id="start-by-proving-the-url-is-reachable">Start by proving the URL is reachable</h2>

<p>Before touching provider settings, I check the public URL from outside my app.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> https://dev-my-app.example.com/webhooks/stripe
</code></pre></div></div>

<p>For a webhook endpoint, <code class="language-plaintext highlighter-rouge">GET</code> might return <code class="language-plaintext highlighter-rouge">404</code> or <code class="language-plaintext highlighter-rouge">405</code>. That’s fine. I only care that the hostname resolves, TLS works, and the request reaches my app.</p>

<p>Then I send a fake <code class="language-plaintext highlighter-rouge">POST</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="se">\</span>
  <span class="nt">-X</span> POST <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{"ping":true}'</span> <span class="se">\</span>
  https://dev-my-app.example.com/webhooks/stripe
</code></pre></div></div>

<p>If this never appears in my local logs, I stop there and fix the tunnel, hostname, port, or route before touching provider settings.</p>

<p>For my own projects I usually use <a href="/configuring-cloudflare-tunnel-to-expose-servers-for-local-development-webhooks-etc/">Cloudflare Tunnel</a> with a stable dev subdomain. Cloudflare’s docs also show quick tunnels for development, which give you a public <code class="language-plaintext highlighter-rouge">trycloudflare.com</code> hostname.</p>

<p>I use a named tunnel with my own dev hostname because provider dashboards remember URLs, and random URLs turn local testing into dashboard bookkeeping.</p>

<h2 id="do-not-start-with-the-provider-dashboard">Do not start with the provider dashboard</h2>

<p>I open the provider dashboard after I know my URL reaches my local server.</p>

<p>I want this order:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">curl</code> reaches the public URL</li>
  <li>the public URL reaches my local server</li>
  <li>my local server logs the route, method, headers, and status</li>
  <li>then I configure the provider</li>
</ul>

<p>When I skip that order, I waste time reading provider delivery logs for a URL my laptop never received.</p>

<p>A tiny temporary log helps:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">"</span><span class="s2">/webhooks/stripe</span><span class="dl">"</span><span class="p">,</span> <span class="nx">express</span><span class="p">.</span><span class="nx">raw</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">*/*</span><span class="dl">"</span> <span class="p">}),</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">stripe webhook</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">method</span><span class="p">,</span>
    <span class="na">path</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span>
    <span class="na">contentType</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">content-type</span><span class="dl">"</span><span class="p">),</span>
    <span class="na">stripeSignature</span><span class="p">:</span> <span class="nb">Boolean</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">stripe-signature</span><span class="dl">"</span><span class="p">)),</span>
    <span class="na">bytes</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span>
  <span class="p">});</span>

  <span class="nx">res</span><span class="p">.</span><span class="nx">sendStatus</span><span class="p">(</span><span class="mi">200</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>I keep that log noisy while wiring things up. Once requests arrive reliably, I cut it back.</p>

<h2 id="the-raw-body-problem">The raw body problem</h2>

<p>Most webhook providers sign the raw request body.</p>

<p>Most signature failures I see locally come from losing those original bytes.</p>

<p>Stripe’s docs are explicit: signature verification needs the raw body, the <code class="language-plaintext highlighter-rouge">Stripe-Signature</code> header, and the endpoint secret. GitHub signs deliveries with <code class="language-plaintext highlighter-rouge">X-Hub-Signature-256</code> when you configure a secret. Shopify signs HTTPS deliveries with <code class="language-plaintext highlighter-rouge">X-Shopify-Hmac-SHA256</code> generated from the raw request body.</p>

<p>If your framework parses JSON before your webhook code runs, the bytes you verify may no longer be the bytes the provider signed.</p>

<p>In Express, this is the usual mistake:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nx">json</span><span class="p">());</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">"</span><span class="s2">/webhooks/stripe</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// req.body is already parsed.</span>
  <span class="c1">// Stripe signature verification can fail here.</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Use a raw body parser for the webhook route:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span>
  <span class="dl">"</span><span class="s2">/webhooks/stripe</span><span class="dl">"</span><span class="p">,</span>
  <span class="nx">express</span><span class="p">.</span><span class="nx">raw</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span> <span class="p">}),</span>
  <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">signature</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">stripe-signature</span><span class="dl">"</span><span class="p">);</span>

    <span class="kd">const</span> <span class="nx">event</span> <span class="o">=</span> <span class="nx">stripe</span><span class="p">.</span><span class="nx">webhooks</span><span class="p">.</span><span class="nx">constructEvent</span><span class="p">(</span>
      <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span>
      <span class="nx">signature</span><span class="p">,</span>
      <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">STRIPE_WEBHOOK_SECRET</span><span class="o">!</span><span class="p">,</span>
    <span class="p">);</span>

    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">received</span><span class="dl">"</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="kd">type</span><span class="p">);</span>
    <span class="nx">res</span><span class="p">.</span><span class="nx">sendStatus</span><span class="p">(</span><span class="mi">200</span><span class="p">);</span>
  <span class="p">},</span>
<span class="p">);</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nx">json</span><span class="p">());</span>
</code></pre></div></div>

<p>In Express, put the webhook route before <code class="language-plaintext highlighter-rouge">app.use(express.json())</code>. In other frameworks, use whatever hook gives the handler the raw request body.</p>

<p>The same rule applies outside Express. Before verifying the signature, get the original request body in the exact form the provider expects.</p>

<h2 id="local-webhook-secrets-are-often-different">Local webhook secrets are often different</h2>

<p>I always check which endpoint created the secret.</p>

<p>Stripe is the easiest place to see this. A local <code class="language-plaintext highlighter-rouge">stripe listen</code> session gives you a signing secret for that forwarding session. A webhook endpoint created in the Stripe dashboard has its own signing secret. If I use the dashboard endpoint secret while testing <code class="language-plaintext highlighter-rouge">stripe listen</code>, signature verification fails.</p>

<p>Same practical rule for other providers: check which endpoint generated the secret.</p>

<p>My <code class="language-plaintext highlighter-rouge">.env</code> usually makes that explicit:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">STRIPE_SECRET_KEY</span><span class="o">=</span>sk_test_...
<span class="nv">STRIPE_WEBHOOK_SECRET</span><span class="o">=</span>whsec_from_the_current_endpoint
</code></pre></div></div>

<p>Not:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">WEBHOOK_SECRET</span><span class="o">=</span>some_secret_i_found_somewhere
</code></pre></div></div>

<p>I name it after the provider and endpoint so I don’t grab the wrong secret later.</p>

<h2 id="webhooks-are-not-browser-requests">Webhooks are not browser requests</h2>

<p>I keep browser debugging separate from webhook debugging.</p>

<p>For a browser call, I look at CORS, cookies, origins, preflight requests, and frontend dev servers.</p>

<p>A webhook delivery is server-to-server. The provider’s server sends an HTTP request to your endpoint. There is no browser enforcing CORS on that delivery.</p>

<p>CORS can still show up if you build a local admin page that calls your webhook route from the browser. In that case, your browser is calling your route. Stripe, GitHub, and Shopify are not delivering a webhook.</p>

<p>I separate the two tests:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Webhook-style server request</span>
curl <span class="nt">-i</span> <span class="nt">-X</span> POST https://dev-my-app.example.com/webhooks/provider
</code></pre></div></div>

<p>and:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>browser app -&gt; backend API
</code></pre></div></div>

<p>I don’t debug those with the same checklist.</p>

<h2 id="return-fast-do-work-after">Return fast, do work after</h2>

<p>A webhook handler should usually do three things synchronously:</p>

<ul>
  <li>verify the signature</li>
  <li>store enough information to process the event once</li>
  <li>return a success response</li>
</ul>

<p>Then a job or background worker does the real work.</p>

<p>If my handler sends email, calls three APIs, writes five records, and then returns <code class="language-plaintext highlighter-rouge">200</code>, every local test has too many places to fail.</p>

<p>I prefer a small local first pass:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">"</span><span class="s2">/webhooks/stripe</span><span class="dl">"</span><span class="p">,</span> <span class="nx">rawBody</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">event</span> <span class="o">=</span> <span class="nx">verifyStripeEvent</span><span class="p">(</span><span class="nx">req</span><span class="p">);</span>

  <span class="k">await</span> <span class="nx">db</span><span class="p">.</span><span class="nx">webhookEvent</span><span class="p">.</span><span class="nx">upsert</span><span class="p">({</span>
    <span class="na">where</span><span class="p">:</span> <span class="p">{</span> <span class="na">provider_event_id</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">id</span> <span class="p">},</span>
    <span class="na">create</span><span class="p">:</span> <span class="p">{</span>
      <span class="na">provider</span><span class="p">:</span> <span class="dl">"</span><span class="s2">stripe</span><span class="dl">"</span><span class="p">,</span>
      <span class="na">provider_event_id</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
      <span class="na">type</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="kd">type</span><span class="p">,</span>
      <span class="na">payload</span><span class="p">:</span> <span class="nx">event</span><span class="p">,</span>
    <span class="p">},</span>
    <span class="na">update</span><span class="p">:</span> <span class="p">{},</span>
  <span class="p">});</span>

  <span class="nx">res</span><span class="p">.</span><span class="nx">sendStatus</span><span class="p">(</span><span class="mi">200</span><span class="p">);</span>

  <span class="nx">queue</span><span class="p">.</span><span class="nx">enqueue</span><span class="p">(</span><span class="dl">"</span><span class="s2">process-stripe-event</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">eventID</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">id</span> <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<p>In my apps, the handler acknowledges receipt after verification and durable storage, then processes separately.</p>

<h2 id="expect-duplicate-and-out-of-order-deliveries">Expect duplicate and out-of-order deliveries</h2>

<p>Local testing often hides this because you click “send test webhook” once and watch one request.</p>

<p>In production, Stripe can retry failed deliveries, GitHub lets me replay recent deliveries, and related events can arrive in a different order from the one I expected.</p>

<p>Stripe documents duplicate events and says not to depend on event ordering. Shopify includes a delivery ID you can use to detect duplicates. GitHub has delivery IDs and replay tooling in its webhook UI.</p>

<p>My table usually has at least:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>provider
provider_event_id
event_type
received_at
processed_at
status
payload
</code></pre></div></div>

<p>For GitHub, the provider event ID might be the delivery ID. For Stripe, it is usually the event object’s <code class="language-plaintext highlighter-rouge">id</code>. Save the ID you need to make processing idempotent.</p>

<p>If I receive the same event twice, the second request should not create a second subscription, send a second welcome email, or mark an order twice.</p>

<h2 id="the-diagnostic-checklist-i-use">The diagnostic checklist I use</h2>

<p>When a local webhook fails, I go through this list in order:</p>

<ul>
  <li><strong>Can the provider reach the hostname?</strong> Check DNS, TLS, tunnel status, and whether the public URL maps to the correct local port.</li>
  <li><strong>Is the provider using the exact URL?</strong> Watch for missing path prefixes, trailing slash <code class="language-plaintext highlighter-rouge">3xx</code> responses, old random tunnel URLs, and staging vs local domains.</li>
  <li><strong>Does the route accept <code class="language-plaintext highlighter-rouge">POST</code>?</strong> Providers won’t call your nice browser-only <code class="language-plaintext highlighter-rouge">GET</code> route.</li>
  <li><strong>Does the app log the request before verification?</strong> If not, the request is dying before your handler.</li>
  <li><strong>Am I reading the raw body?</strong> Signature verification should happen before JSON parsing changes the body.</li>
  <li><strong>Is the secret from this endpoint?</strong> Dashboard endpoint, CLI forwarding session, app-level secret, and old rotated secret are different things.</li>
  <li><strong>Am I returning a <code class="language-plaintext highlighter-rouge">2xx</code> quickly?</strong> <code class="language-plaintext highlighter-rouge">3xx</code>, <code class="language-plaintext highlighter-rouge">4xx</code>, <code class="language-plaintext highlighter-rouge">5xx</code>, and timeouts are failures for most providers.</li>
  <li><strong>Can I replay the same delivery?</strong> Use the provider’s resend/replay tool, or save the raw request in local logs while debugging.</li>
  <li><strong>Is the processing idempotent?</strong> Assume duplicates and out-of-order events.</li>
</ul>

<p>When I follow that order, I usually find the bad assumption quickly.</p>

<h2 id="a-good-local-setup-feels-boring">A good local setup feels boring</h2>

<p>The local setup I use:</p>

<ul>
  <li>a stable public dev hostname</li>
  <li>a tunnel that points to the right local port</li>
  <li>a webhook route that logs receipt before doing work</li>
  <li>provider-specific signature verification using the raw body</li>
  <li>explicit endpoint secret names in <code class="language-plaintext highlighter-rouge">.env</code></li>
  <li>durable event storage</li>
  <li>a way to replay the event</li>
</ul>

<p>At that point, the work is normal backend code: receive the request, verify it, store the event, process it, and make it safe to run twice.</p>

<p>I fix reachability first, then the handler.</p>]]></content><author><name></name></author><category term="Web" /><category term="Techniques" /><summary type="html"><![CDATA[Webhooks feel hard locally because you are debugging two machines, a public URL, raw request bytes, signatures, retries, and your own handler at the same time.]]></summary></entry><entry><title type="html">The Web Platform Is Full Of Security Features That Look Like Bugs</title><link href="https://hboon.com/the-web-platform-is-full-of-security-features-that-look-like-bugs/" rel="alternate" type="text/html" title="The Web Platform Is Full Of Security Features That Look Like Bugs" /><published>2026-06-25T07:25:00+00:00</published><updated>2026-06-25T07:25:00+00:00</updated><id>https://hboon.com/the-web-platform-is-full-of-security-features-that-look-like-bugs</id><content type="html" xml:base="https://hboon.com/the-web-platform-is-full-of-security-features-that-look-like-bugs/"><![CDATA[<p>The browser breaks things on purpose.</p>

<p>That sounds obvious until you are staring at a request that works in <code class="language-plaintext highlighter-rouge">curl</code>, fails in <code class="language-plaintext highlighter-rouge">fetch</code>, and gives you a console error that reads like the browser is being difficult. Most of the time, the browser is enforcing a security rule your server, iframe, cookie, or local dev setup did not account for.</p>

<p>The useful mental model is simple: <strong>the web platform protects users by making some things fail at the browser boundary</strong>. Your job is to find which boundary you hit before changing app code.</p>

<h2 id="start-by-proving-which-layer-blocked-it">Start by proving which layer blocked it</h2>

<p>I split these failures into two buckets:</p>

<ul>
  <li><strong>The request did not happen</strong>: the browser blocked the request before it reached your server.</li>
  <li><strong>The request happened, but JavaScript cannot use the result</strong>: the server may have responded, but the browser withheld the response from your code.</li>
</ul>

<p>A request that never reached the server needs a different fix from a response your JavaScript was not allowed to read.</p>

<p>This is the first pass I use in DevTools:</p>

<ul>
  <li><strong>Network tab</strong>: did the request appear?</li>
  <li><strong>Status code</strong>: did the server return <code class="language-plaintext highlighter-rouge">200</code>, <code class="language-plaintext highlighter-rouge">204</code>, <code class="language-plaintext highlighter-rouge">302</code>, <code class="language-plaintext highlighter-rouge">401</code>, or something else?</li>
  <li><strong>Initiator</strong>: was it your code, an iframe, an image, a script tag, or a preflight?</li>
  <li><strong>Request headers</strong>: check <code class="language-plaintext highlighter-rouge">Origin</code>, <code class="language-plaintext highlighter-rouge">Cookie</code>, <code class="language-plaintext highlighter-rouge">Authorization</code>, <code class="language-plaintext highlighter-rouge">Access-Control-Request-Method</code>, and <code class="language-plaintext highlighter-rouge">Access-Control-Request-Headers</code>.</li>
  <li><strong>Response headers</strong>: check <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code>, <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Credentials</code>, <code class="language-plaintext highlighter-rouge">Set-Cookie</code>, <code class="language-plaintext highlighter-rouge">Content-Security-Policy</code>, <code class="language-plaintext highlighter-rouge">Permissions-Policy</code>, <code class="language-plaintext highlighter-rouge">Cross-Origin-Opener-Policy</code>, and <code class="language-plaintext highlighter-rouge">Cross-Origin-Embedder-Policy</code>.</li>
  <li><strong>Console</strong>: read it after the Network tab, not before.</li>
</ul>

<p>The console is good at telling you the rule name. The Network tab is better at telling you what happened.</p>

<h2 id="common-policies-that-look-like-bugs">Common policies that look like bugs</h2>

<h3 id="cors-is-not-api-auth">CORS is not API auth</h3>

<p>I wrote a separate post on <a href="/stop-fixing-cors-by-disabling-cors/">not fixing CORS by disabling CORS</a>. The same debugging rule applies here.</p>

<p>CORS is a browser-enforced read permission for cross-origin responses. It does not protect your API from <code class="language-plaintext highlighter-rouge">curl</code>, backend jobs, CLI scripts, or attackers who call the API directly. It controls whether JavaScript running on one origin can read a response from another origin.</p>

<p>MDN’s <a href="https://developer.mozilla.org/en-US/docs/Glossary/Origin">Origin docs</a> define an origin as the scheme, hostname, and port. A different port or scheme gives you a different origin:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://localhost:5173
http://localhost:5174
https://localhost:5173
https://app.example.com
https://www.example.com
</code></pre></div></div>

<p>A request like this can fail even when the endpoint exists:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://api.example.com/projects</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
    <span class="dl">"</span><span class="s2">content-type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">authorization</span><span class="p">:</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="nx">token</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
  <span class="p">},</span>
  <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Demo</span><span class="dl">"</span> <span class="p">}),</span>
<span class="p">});</span>
</code></pre></div></div>

<p>That request usually triggers a <a href="https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request">CORS preflight</a> because it is cross-origin and uses headers/methods outside the simple request path. The browser first sends an <code class="language-plaintext highlighter-rouge">OPTIONS</code> request asking whether the real request is allowed.</p>

<p>You can reproduce the preflight:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-X</span> OPTIONS <span class="s1">'https://api.example.com/projects'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Origin: https://app.example.com'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Access-Control-Request-Method: POST'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Access-Control-Request-Headers: content-type, authorization'</span>
</code></pre></div></div>

<p>The response needs to answer that request:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">HTTP/2 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: content-type,authorization
Vary: Origin
</span></code></pre></div></div>

<p>If cookies are involved, wildcard origins are out:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
</span></code></pre></div></div>

<p>That combination does not work for credentialed browser requests. MDN’s <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials"><code class="language-plaintext highlighter-rouge">Access-Control-Allow-Credentials</code></a> docs cover the credential side of this. Return the exact allowed origin and include the <code class="language-plaintext highlighter-rouge">Vary</code> response header with <code class="language-plaintext highlighter-rouge">Origin</code>.</p>

<p>The diagnostic rule: if it works in <code class="language-plaintext highlighter-rouge">curl</code> and fails in the browser, check the browser contract, not just the API handler.</p>

<h3 id="cookies-can-be-set-and-still-not-be-sent">Cookies can be set and still not be sent</h3>

<p>Cookie bugs are good at looking like auth bugs.</p>

<p>You log in. The response has <code class="language-plaintext highlighter-rouge">Set-Cookie</code>. The next API request still looks logged out. It is tempting to debug the session store first. Sometimes the browser rejected the cookie, stored it under a different site context, or decided not to send it.</p>

<p>Check the cookie in DevTools:</p>

<ul>
  <li>Was the <code class="language-plaintext highlighter-rouge">Set-Cookie</code> response accepted?</li>
  <li>Is it under the domain you expect?</li>
  <li>Is <code class="language-plaintext highlighter-rouge">Path</code> too narrow?</li>
  <li>Is <code class="language-plaintext highlighter-rouge">Secure</code> present for HTTPS-only cookies?</li>
  <li>Is <code class="language-plaintext highlighter-rouge">HttpOnly</code> present if JavaScript should not read it?</li>
  <li>Is <code class="language-plaintext highlighter-rouge">SameSite</code> right for the navigation or embedded flow?</li>
</ul>

<p>For cross-site cookies, the practical default is:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Set-Cookie: session=abc; Path=/; HttpOnly; Secure; SameSite=None
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SameSite=None</code> requires <code class="language-plaintext highlighter-rouge">Secure</code> in modern browsers. Cookies without a <code class="language-plaintext highlighter-rouge">SameSite</code> attribute are commonly treated as <code class="language-plaintext highlighter-rouge">Lax</code>, which means they may not be sent in the embedded or cross-site request you expected. MDN’s <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie"><code class="language-plaintext highlighter-rouge">Set-Cookie</code></a> page is the one I check when I forget the exact attribute behavior.</p>

<p>Also check the frontend call:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://api.example.com/me</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">credentials</span><span class="p">:</span> <span class="dl">"</span><span class="s2">include</span><span class="dl">"</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">credentials</code> is missing, <code class="language-plaintext highlighter-rouge">fetch</code> will not include cookies on cross-origin requests. If <code class="language-plaintext highlighter-rouge">credentials</code> is present but your CORS response does not allow credentials, JavaScript still cannot use the response.</p>

<p>The diagnostic rule: inspect the cookie storage and the request’s <code class="language-plaintext highlighter-rouge">Cookie</code> header. Do not infer cookie behavior from the login response alone.</p>

<h3 id="https-pages-cannot-freely-load-http-resources">HTTPS pages cannot freely load HTTP resources</h3>

<p>Mixed content is another “but the URL works when I open it” trap.</p>

<p>An HTTPS page loading <code class="language-plaintext highlighter-rouge">http://</code> scripts, stylesheets, fonts, iframes, or API calls is not the same as opening that HTTP URL in a tab. The page is secure; the subresource is not. The browser may upgrade the request to HTTPS or block it, depending on the resource type and browser behavior. MDN’s <a href="https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content">mixed content</a> docs describe the current upgradable/blockable split.</p>

<p>This often appears after moving a site behind HTTPS:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"http://cdn.example.com/widget.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"http://images.example.com/logo.png"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>Fix the URLs at the source:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.example.com/widget.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"https://images.example.com/logo.png"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>Do not paper over this by telling people to allow insecure content in the browser. That only hides the production problem.</p>

<p>The diagnostic rule: search the generated HTML and runtime config for <code class="language-plaintext highlighter-rouge">http://</code>, not just source files.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> https://app.example.com | rg <span class="s1">'http://'</span>
</code></pre></div></div>

<p>For built frontend apps, also inspect generated assets:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rg <span class="s1">'http://'</span> dist .next build public
</code></pre></div></div>

<h3 id="csp-turns-it-loaded-yesterday-into-a-policy-error">CSP turns “it loaded yesterday” into a policy error</h3>

<p><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">Content Security Policy</a> is one of the most useful browser security features and one of the easiest to mistake for random breakage.</p>

<p>You add an analytics script, payment widget, image CDN, iframe, or inline script. The browser blocks it. The app code did not change much, but the page has a policy that says which sources are allowed.</p>

<p>Example:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:
</span></code></pre></div></div>

<p>This policy says scripts must come from the same origin. A third-party script will be blocked:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://analytics.example.com/script.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p>Keep CSP enabled and add the narrow source you need:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Content-Security-Policy: default-src 'self'; script-src 'self' https://analytics.example.com; img-src 'self' data:
</span></code></pre></div></div>

<p>For inline scripts, prefer a nonce or hash. Do not add <code class="language-plaintext highlighter-rouge">'unsafe-inline'</code> unless you have decided the tradeoff explicitly.</p>

<p>CSP has a good test mode:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'
</span></code></pre></div></div>

<p>That reports violations without enforcing the policy. I use <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only"><code class="language-plaintext highlighter-rouge">Content-Security-Policy-Report-Only</code></a> when tightening a real app because it tells me what would break before I break it.</p>

<p>The diagnostic rule: when the console says “Refused to load”, read the effective directive and the blocked URL. CSP errors usually include both.</p>

<h3 id="iframes-have-their-own-permissions">Iframes have their own permissions</h3>

<p>Iframe failures often look like the child app is broken. Sometimes the parent page did not grant the feature.</p>

<p>There are two common places to check.</p>

<p>First, the iframe’s <code class="language-plaintext highlighter-rouge">allow</code> attribute:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;iframe</span>
  <span class="na">src=</span><span class="s">"https://checkout.example.com"</span>
  <span class="na">allow=</span><span class="s">"camera; microphone; payment"</span>
<span class="nt">&gt;&lt;/iframe&gt;</span>
</code></pre></div></div>

<p>Second, the page’s <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy"><code class="language-plaintext highlighter-rouge">Permissions-Policy</code></a> header:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Permissions-Policy: camera=(), microphone=(), geolocation=()
</span></code></pre></div></div>

<p>That header can disable browser features for the document and nested browsing contexts. If the parent denies camera access, the embedded page cannot fix that by calling <code class="language-plaintext highlighter-rouge">getUserMedia()</code> harder.</p>

<p>The same pattern shows up with sandboxed iframes:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;iframe</span>
  <span class="na">src=</span><span class="s">"https://tool.example.com"</span>
  <span class="na">sandbox=</span><span class="s">"allow-scripts allow-forms"</span>
<span class="nt">&gt;&lt;/iframe&gt;</span>
</code></pre></div></div>

<p>Without the right sandbox tokens, navigation, popups, forms, scripts, or same-origin behavior may be restricted. Sandboxing intentionally removes capabilities until the parent page grants them back.</p>

<p>The diagnostic rule: debug the parent page and iframe attributes before changing the embedded app.</p>

<h3 id="isolation-headers-can-break-popups-and-embedded-resources">Isolation headers can break popups and embedded resources</h3>

<p>Cross-origin isolation is useful for powerful APIs such as <code class="language-plaintext highlighter-rouge">SharedArrayBuffer</code>, but the headers are strict. Google’s <a href="https://web.dev/articles/why-coop-coep">COOP/COEP explainer</a> is still a useful overview when you need the practical consequence rather than only the header syntax.</p>

<p>The usual set looks like this:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</span></code></pre></div></div>

<p>COOP can separate your top-level page from cross-origin popups. COEP can require embedded cross-origin resources to explicitly opt in with CORS or <code class="language-plaintext highlighter-rouge">Cross-Origin-Resource-Policy</code>.</p>

<p>That means a third-party script, worker, image, or wasm file that loaded before may stop loading after you enable isolation. The browser is not confused. You asked it to isolate the page.</p>

<p>Check this in the console:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">window</span><span class="p">.</span><span class="nx">crossOriginIsolated</span>
</code></pre></div></div>

<p>If it is <code class="language-plaintext highlighter-rouge">false</code>, inspect the blocked resource. If it is <code class="language-plaintext highlighter-rouge">true</code> and popups or embeds changed behavior, inspect the COOP/COEP headers on the top-level page and the resource headers on the things it embeds.</p>

<p>The diagnostic rule: when you enable isolation, audit every cross-origin script, worker, iframe, and binary resource.</p>

<h2 id="my-usual-debugging-order">My usual debugging order</h2>

<p>When a browser feature looks like a bug, I use this order:</p>

<ul>
  <li>Reproduce it in a clean browser profile or private window.</li>
  <li>Open Network and preserve logs.</li>
  <li>Check whether the request happened.</li>
  <li>Check request and response headers before app code.</li>
  <li>Compare browser behavior with <code class="language-plaintext highlighter-rouge">curl</code>, but do not treat <code class="language-plaintext highlighter-rouge">curl</code> as proof that browser code should work.</li>
  <li>Search generated output for stale origins, <code class="language-plaintext highlighter-rouge">http://</code> URLs, and old environment values.</li>
  <li>Reduce the failing case to one HTML file or one <code class="language-plaintext highlighter-rouge">fetch</code> call.</li>
  <li>Only then change server or frontend code.</li>
</ul>

<p>Here is the small test page I reach for when I want to remove framework noise:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!doctype html&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"utf-8"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;button</span> <span class="na">id=</span><span class="s">"run"</span><span class="nt">&gt;</span>Run request<span class="nt">&lt;/button&gt;</span>
<span class="nt">&lt;pre</span> <span class="na">id=</span><span class="s">"out"</span><span class="nt">&gt;&lt;/pre&gt;</span>
<span class="nt">&lt;script&gt;</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">#run</span><span class="dl">"</span><span class="p">).</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">click</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">#out</span><span class="dl">"</span><span class="p">);</span>

    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://api.example.com/me</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">credentials</span><span class="p">:</span> <span class="dl">"</span><span class="s2">include</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
          <span class="dl">"</span><span class="s2">content-type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">,</span>
        <span class="p">},</span>
      <span class="p">});</span>

      <span class="nx">out</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">\n</span><span class="p">${</span><span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nx">text</span><span class="p">()}</span><span class="s2">`</span><span class="p">;</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">out</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="nx">error</span><span class="p">);</span>
      <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="nx">error</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">});</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Serve it from the same origin as your app or from the origin you are testing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 <span class="nt">-m</span> http.server 5173
</code></pre></div></div>

<p>Then compare the exact <code class="language-plaintext highlighter-rouge">Origin</code> header, preflight, cookies, and response headers with your real app.</p>

<h2 id="the-browser-is-doing-policy-enforcement">The browser is doing policy enforcement</h2>

<p>These failures feel inconsistent because different browser APIs have different defaults:</p>

<ul>
  <li>CORS controls whether browser JavaScript can read cross-origin responses.</li>
  <li>SameSite controls when cookies are sent in same-site and cross-site contexts.</li>
  <li>Mixed content protects HTTPS pages from insecure subresources.</li>
  <li>CSP controls which resources and script execution patterns a page allows.</li>
  <li>Permissions Policy and iframe attributes control which browser features embedded content can use.</li>
  <li>COOP and COEP change opener relationships and cross-origin embedding rules.</li>
</ul>

<p>The common thread is not “browser weirdness”. It is policy enforcement at the browser boundary.</p>

<p>Once I look for the policy first, the bug usually gets smaller.</p>]]></content><author><name></name></author><category term="Web" /><category term="Techniques" /><summary type="html"><![CDATA[How I debug browser security features that look like broken code: CORS, SameSite cookies, mixed content, CSP, iframe permissions, and isolation headers.]]></summary></entry><entry><title type="html">Stop Fixing CORS By Disabling CORS</title><link href="https://hboon.com/stop-fixing-cors-by-disabling-cors/" rel="alternate" type="text/html" title="Stop Fixing CORS By Disabling CORS" /><published>2026-06-25T07:07:00+00:00</published><updated>2026-06-25T07:07:00+00:00</updated><id>https://hboon.com/stop-fixing-cors-by-disabling-cors</id><content type="html" xml:base="https://hboon.com/stop-fixing-cors-by-disabling-cors/"><![CDATA[<p>CORS errors make people do strange things.</p>

<p>The usual fix I see is some version of “disable CORS”, “allow all origins”, or “install a browser extension”. That might get the request through in development, but it also hides the actual problem: the browser asked your server whether this frontend is allowed to read the response, and your server answered badly.</p>

<p>Fix it by making the browser, frontend, and backend agree on the request.</p>

<h2 id="the-mental-model">The mental model</h2>

<p>CORS is a browser-enforced HTTP contract.</p>

<p>Your frontend says:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Origin: https://app.example.com
</span></code></pre></div></div>

<p>Your backend says:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Access-Control-Allow-Origin: https://app.example.com
</span></code></pre></div></div>

<p>If those line up, the browser lets your JavaScript read the response. If they do not, the browser blocks your JavaScript from reading it.</p>

<p>CORS controls browser access to responses. API authentication is a separate layer. It does not stop <code class="language-plaintext highlighter-rouge">curl</code>, another backend, a CLI script, or someone calling your API directly. It controls whether browser JavaScript from another origin can read the response. MDN’s CORS guide puts it in the same terms: browsers restrict script-initiated cross-origin requests unless the response includes the right CORS headers.</p>

<p>An origin is not “the domain”. It is:</p>

<ul>
  <li>scheme</li>
  <li>host</li>
  <li>port</li>
</ul>

<p>All of these are different origins:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://localhost:5173
http://localhost:5174
https://localhost:5173
https://app.example.com
https://www.example.com
</code></pre></div></div>

<p>If your Vite app runs on <code class="language-plaintext highlighter-rouge">http://localhost:5173</code> and your API allows <code class="language-plaintext highlighter-rouge">http://localhost:3000</code>, CORS is working correctly when the browser blocks it.</p>

<h2 id="check-the-failing-request-first">Check the failing request first</h2>

<p>Open DevTools and click the failing request.</p>

<p>I check these fields before touching server code:</p>

<ul>
  <li><strong>Request URL</strong>: the API URL the browser actually called</li>
  <li><strong>Request Method</strong>: <code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">POST</code>, <code class="language-plaintext highlighter-rouge">OPTIONS</code>, etc.</li>
  <li><strong>Origin</strong>: the frontend origin the browser sent</li>
  <li><strong>Request Headers</strong>: especially <code class="language-plaintext highlighter-rouge">authorization</code>, <code class="language-plaintext highlighter-rouge">content-type</code>, and custom <code class="language-plaintext highlighter-rouge">x-*</code> headers</li>
  <li><strong>Response Headers</strong>: <code class="language-plaintext highlighter-rouge">access-control-allow-origin</code>, <code class="language-plaintext highlighter-rouge">access-control-allow-credentials</code>, <code class="language-plaintext highlighter-rouge">access-control-allow-methods</code>, <code class="language-plaintext highlighter-rouge">access-control-allow-headers</code></li>
  <li><strong>Status code</strong>: whether the failure is on the real request or the preflight <code class="language-plaintext highlighter-rouge">OPTIONS</code> request</li>
</ul>

<p>Do not debug CORS from the console message alone. The console usually tells you the symptom. The Network tab tells you what the browser sent and what the server answered.</p>

<h2 id="reproduce-the-preflight-with-curl">Reproduce the preflight with curl</h2>

<p>When the browser sends a non-simple request, it may send an <code class="language-plaintext highlighter-rouge">OPTIONS</code> preflight first. The preflight asks the server whether the real request is allowed.</p>

<p>I usually reproduce it like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-X</span> OPTIONS <span class="s1">'https://api.example.com/projects'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Origin: https://app.example.com'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Access-Control-Request-Method: POST'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Access-Control-Request-Headers: content-type, authorization'</span>
</code></pre></div></div>

<p>A useful response looks like:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">HTTP/2 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: content-type,authorization
Vary: Origin
</span></code></pre></div></div>

<p>If the browser request uses cookies or HTTP auth, it also needs:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Access-Control-Allow-Credentials: true
</span></code></pre></div></div>

<p>And the frontend request must opt in:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://api.example.com/projects</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">credentials</span><span class="p">:</span> <span class="dl">"</span><span class="s2">include</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
    <span class="dl">"</span><span class="s2">content-type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">},</span>
  <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">New project</span><span class="dl">"</span> <span class="p">}),</span>
<span class="p">});</span>
</code></pre></div></div>

<p>If your API uses bearer tokens in the <code class="language-plaintext highlighter-rouge">Authorization</code> header, that header still has to be allowed in the preflight:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Access-Control-Allow-Headers: content-type,authorization
</span></code></pre></div></div>

<h2 id="do-not-mix-wildcard-origins-with-credentials">Do not mix wildcard origins with credentials</h2>

<p>This does not work for credentialed browser requests:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
</span></code></pre></div></div>

<p>The Fetch standard is explicit here: when the request’s credentials mode is <code class="language-plaintext highlighter-rouge">include</code>, <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code> cannot be <code class="language-plaintext highlighter-rouge">*</code>. MDN says the same thing for <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code>: wildcard is for requests without credentials.</p>

<p>If cookies are involved, return the exact origin:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
</span></code></pre></div></div>

<p>Also check your cookie settings. Cross-site cookies usually need:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Set-Cookie: session=...; Path=/; HttpOnly; Secure; SameSite=None
</span></code></pre></div></div>

<p>If you are testing on plain <code class="language-plaintext highlighter-rouge">http://localhost</code>, <code class="language-plaintext highlighter-rouge">Secure</code> cookies are another thing to check. The browser may reject or omit the cookie before your API code is even involved.</p>

<h2 id="reflecting-origin-is-fine-only-with-an-allowlist">Reflecting Origin is fine only with an allowlist</h2>

<p>This is the pattern I use:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">allowedOrigins</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="p">([</span>
  <span class="dl">"</span><span class="s2">https://app.example.com</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">https://admin.example.com</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">http://localhost:5173</span><span class="dl">"</span><span class="p">,</span>
<span class="p">]);</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">addHook</span><span class="p">(</span><span class="dl">"</span><span class="s2">onRequest</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="nx">reply</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">origin</span> <span class="o">=</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">origin</span><span class="p">;</span>

  <span class="k">if</span> <span class="p">(</span><span class="nx">origin</span> <span class="o">&amp;&amp;</span> <span class="nx">allowedOrigins</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">origin</span><span class="p">))</span> <span class="p">{</span>
    <span class="nx">reply</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">Access-Control-Allow-Origin</span><span class="dl">"</span><span class="p">,</span> <span class="nx">origin</span><span class="p">);</span>
    <span class="nx">reply</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">Vary</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">Origin</span><span class="dl">"</span><span class="p">);</span>
    <span class="nx">reply</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">Access-Control-Allow-Credentials</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">if</span> <span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">method</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">OPTIONS</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">reply</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">Access-Control-Allow-Methods</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">GET,POST,PUT,DELETE,OPTIONS</span><span class="dl">"</span><span class="p">);</span>
    <span class="nx">reply</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">Access-Control-Allow-Headers</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">content-type,authorization</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">204</span><span class="p">).</span><span class="nx">send</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>

<p>I care more about the allowlist check than the framework code:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">allowedOrigins</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">origin</span><span class="p">)</span>
</code></pre></div></div>

<p>Do not do this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">reply</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">Access-Control-Allow-Origin</span><span class="dl">"</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">origin</span><span class="p">);</span>
<span class="nx">reply</span><span class="p">.</span><span class="nx">header</span><span class="p">(</span><span class="dl">"</span><span class="s2">Access-Control-Allow-Credentials</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>That reflects any website that asks. If your API uses cookies, you just told the browser that any origin can read credentialed responses from your API.</p>

<p>OWASP’s CORS testing guide describes the same thing from the security side: the browser sends <code class="language-plaintext highlighter-rouge">Origin</code>, and the server uses CORS headers to decide whether that cross-origin request is allowed. Make an actual allow/deny decision before reflecting the origin.</p>

<p>If you use Fastify, <code class="language-plaintext highlighter-rouge">@fastify/cors</code> already has the pieces:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="nx">cors</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@fastify/cors</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">allowedOrigins</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="p">([</span>
  <span class="dl">"</span><span class="s2">https://app.example.com</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">https://admin.example.com</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">http://localhost:5173</span><span class="dl">"</span><span class="p">,</span>
<span class="p">]);</span>

<span class="k">await</span> <span class="nx">fastify</span><span class="p">.</span><span class="nx">register</span><span class="p">(</span><span class="nx">cors</span><span class="p">,</span> <span class="p">{</span>
  <span class="nx">origin</span><span class="p">(</span><span class="nx">origin</span><span class="p">,</span> <span class="nx">callback</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">origin</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">callback</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
      <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="nx">callback</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="nx">allowedOrigins</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">origin</span><span class="p">));</span>
  <span class="p">},</span>
  <span class="na">credentials</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">methods</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">GET</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">PUT</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">DELETE</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">OPTIONS</span><span class="dl">"</span><span class="p">],</span>
  <span class="na">allowedHeaders</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">content-type</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">authorization</span><span class="dl">"</span><span class="p">],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The Fastify CORS plugin supports booleans, strings, regexes, arrays, and custom functions for <code class="language-plaintext highlighter-rouge">origin</code>. I prefer the function for production apps because it makes the allowlist explicit and easy to log.</p>

<h2 id="separate-browser-access-from-api-access">Separate browser access from API access</h2>

<p>I treat these as different checks:</p>

<ul>
  <li><strong>Can the request reach the API?</strong> Check DNS, tunnels, routes, reverse proxies, and HTTP status.</li>
  <li><strong>Can the browser read the response?</strong> Check CORS response headers.</li>
  <li><strong>Is the user allowed to do this?</strong> Check sessions, tokens, CSRF, roles, and billing.</li>
</ul>

<p>Debug them separately.</p>

<p>If <code class="language-plaintext highlighter-rouge">curl</code> works but the browser fails, that usually means the API is reachable and the CORS contract is wrong.</p>

<p>If the preflight fails with <code class="language-plaintext highlighter-rouge">404</code>, <code class="language-plaintext highlighter-rouge">405</code>, or a <code class="language-plaintext highlighter-rouge">3xx</code> response, your server or proxy probably is not handling <code class="language-plaintext highlighter-rouge">OPTIONS</code> on that route.</p>

<p>If the preflight succeeds but the real request fails, compare the real response headers. I have seen APIs add CORS headers to <code class="language-plaintext highlighter-rouge">204</code> preflight responses but not to <code class="language-plaintext highlighter-rouge">401</code>, <code class="language-plaintext highlighter-rouge">403</code>, or <code class="language-plaintext highlighter-rouge">500</code> responses. The browser still needs CORS headers on the response it is trying to expose.</p>

<p>If the request succeeds without cookies but fails after adding <code class="language-plaintext highlighter-rouge">credentials: "include"</code>, check the exact <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code> value first. A wildcard origin stops being valid for that request. After that, check whether <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Credentials: true</code> is present and whether the browser sent the cookie.</p>

<p>If your local frontend is calling your local backend through public dev domains, use the actual dev domain origin in the allowlist. For local webhook and callback testing, I prefer my <a href="/configuring-cloudflare-tunnel-to-expose-servers-for-local-development-webhooks-etc/">Cloudflare Tunnel setup</a> because it keeps the browser origin close to production.</p>

<h2 id="when--is-fine">When <code class="language-plaintext highlighter-rouge">*</code> is fine</h2>

<p><code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin: *</code> is fine for public, non-credentialed resources:</p>

<ul>
  <li>public JSON metadata</li>
  <li>public images or generated assets</li>
  <li>open download endpoints</li>
  <li>unauthenticated demo APIs where the response is meant to be read by any site</li>
</ul>

<p>It is not fine as the default for an authenticated app API.</p>

<p>For SaaS apps, my default is:</p>

<ul>
  <li>exact allowed origins</li>
  <li><code class="language-plaintext highlighter-rouge">Vary: Origin</code> when returning the request origin</li>
  <li>credentials enabled only when I use cookies or HTTP auth</li>
  <li>explicit allowed methods and headers</li>
  <li><code class="language-plaintext highlighter-rouge">OPTIONS</code> handled before auth middleware rejects it</li>
  <li>CORS headers included on error responses too</li>
</ul>

<p>That fixes the real problem. The browser stops complaining because the server is now saying exactly what it means.</p>

<p>Sources I checked while writing this: <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS">MDN’s CORS guide</a>, <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin">MDN on <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code></a>, the <a href="https://fetch.spec.whatwg.org/#cors-protocol-and-credentials">WHATWG Fetch standard’s CORS credential rules</a>, <a href="https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/07-Testing_Cross_Origin_Resource_Sharing">OWASP’s CORS testing guide</a>, and the <a href="https://github.com/fastify/fastify-cors"><code class="language-plaintext highlighter-rouge">@fastify/cors</code> options</a>.</p>]]></content><author><name></name></author><category term="Web" /><category term="Techniques" /><summary type="html"><![CDATA[CORS errors usually mean the browser is enforcing the API contract your server declared. Fix the contract instead of turning the check off.]]></summary></entry><entry><title type="html">Your Browser Is Not Your Backend’s Bodyguard</title><link href="https://hboon.com/your-browser-is-not-your-backend-s-bodyguard/" rel="alternate" type="text/html" title="Your Browser Is Not Your Backend’s Bodyguard" /><published>2026-06-25T06:48:00+00:00</published><updated>2026-06-25T06:48:00+00:00</updated><id>https://hboon.com/your-browser-is-not-your-backend-s-bodyguard</id><content type="html" xml:base="https://hboon.com/your-browser-is-not-your-backend-s-bodyguard/"><![CDATA[<p>I see this mistake a lot in small web apps. The browser blocks something, so the backend is treated as protected.</p>

<p>I would not build on that mental model. Browser security mostly protects the user sitting in the browser. Your backend still has to protect itself from every client that can send HTTP.</p>

<h2 id="what-the-browser-actually-does">What the browser actually does</h2>

<p>The browser has real security rules.</p>

<p><a href="https://developer.mozilla.org/en-US/docs/Glossary/Same-origin_policy">Same-origin policy</a> restricts how a script loaded from one origin can interact with resources from another origin.</p>

<p><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS">CORS</a> lets a server tell the browser which other origins should be allowed to read a response. For some cross-origin requests, the browser sends a preflight request first and checks whether the server allows the actual method and headers.</p>

<p>The browser enforces that rule.</p>

<p>Say your frontend at <code class="language-plaintext highlighter-rouge">https://app.example.com</code> runs a request like this.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://api.example.com/admin/users</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DELETE</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
    <span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">},</span>
  <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="na">userID</span><span class="p">:</span> <span class="dl">"</span><span class="s2">123</span><span class="dl">"</span> <span class="p">}),</span>
<span class="p">})</span>
</code></pre></div></div>

<p>the browser may block the frontend from reading the response unless the API sends the right CORS headers.</p>

<p>That does not mean the backend rejected the operation.</p>

<p>The request may still reach your server. For non-browser clients, CORS is not the gate at all:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-X</span> DELETE <span class="s2">"https://api.example.com/admin/users"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer stolen-or-low-privilege-token"</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{"userID":"123"}'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">curl</code>, Postman, a script running on a VPS, and a compromised mobile app do not care that your React app would not show the button.</p>

<h2 id="frontend-checks-are-for-honest-users">Frontend checks are for honest users</h2>

<p>Frontend validation is useful. I want the form to catch a bad email before it hits the network. The upload picker should reject a 200 MB file immediately. Normal users should not see admin buttons.</p>

<p>But none of those checks should be the reason the backend accepts or rejects the request.</p>

<p>Keep the frontend check:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// frontend</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">file</span><span class="p">.</span><span class="nx">size</span> <span class="o">&gt;</span> <span class="mi">10</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">File is too large</span><span class="dl">"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This still has to exist:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// backend</span>
<span class="kd">const</span> <span class="nx">maxUploadBytes</span> <span class="o">=</span> <span class="mi">10</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">uploadedFile</span><span class="p">.</span><span class="nx">size</span> <span class="o">&gt;</span> <span class="nx">maxUploadBytes</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">413</span><span class="p">).</span><span class="nx">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">File is too large</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Same for plan limits:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// frontend</span>
<span class="kd">const</span> <span class="nx">canGenerate</span> <span class="o">=</span> <span class="nx">currentUsage</span><span class="p">.</span><span class="nx">generatedThisMonth</span> <span class="o">&lt;</span> <span class="nx">plan</span><span class="p">.</span><span class="nx">monthlyGenerations</span>
</code></pre></div></div>

<p>Enforce the same limit on the backend:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// backend</span>
<span class="kd">const</span> <span class="nx">usage</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getUsageForAccount</span><span class="p">(</span><span class="nx">accountID</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">plan</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getPlanForAccount</span><span class="p">(</span><span class="nx">accountID</span><span class="p">)</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">usage</span><span class="p">.</span><span class="nx">generatedThisMonth</span> <span class="o">&gt;=</span> <span class="nx">plan</span><span class="p">.</span><span class="nx">monthlyGenerations</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">402</span><span class="p">).</span><span class="nx">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Generation limit reached</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The frontend makes the happy path nicer. The backend decides what is allowed.</p>

<h2 id="check-the-backend-with-direct-requests">Check the backend with direct requests</h2>

<p>When I review this class of bug, I start by copying requests out of DevTools and changing the values.</p>

<p>In Chrome or Safari:</p>

<ul>
  <li>submit the real form once</li>
  <li>open the Network tab</li>
  <li>copy the request as <code class="language-plaintext highlighter-rouge">curl</code></li>
  <li>remove or change fields</li>
  <li>replay it from the terminal</li>
</ul>

<p>Then I try the requests the UI is supposed to prevent:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Can I update a server-owned field?</span>
curl <span class="nt">-i</span> <span class="nt">-X</span> PATCH <span class="s2">"https://api.example.com/me"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data</span> <span class="s1">'{"name":"Hwee-Boon","role":"admin","plan":"enterprise"}'</span>

<span class="c"># Can I access another user's object?</span>
curl <span class="nt">-i</span> <span class="s2">"https://api.example.com/invoices/inv_other_user"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span>

<span class="c"># Can I run an expensive operation after the UI disables it?</span>
<span class="k">for </span>i <span class="k">in</span> <span class="o">{</span>1..20<span class="o">}</span><span class="p">;</span> <span class="k">do
  </span>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s2">"https://api.example.com/generate"</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> <span class="se">\</span>
    <span class="nt">--data</span> <span class="s1">'{"prompt":"write me a long report"}'</span> <span class="o">&gt;</span>/dev/null
<span class="k">done</span>
</code></pre></div></div>

<p>Use whatever shell you use. Just stop testing only through the UI.</p>

<p>I want to see boring failures:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">401</code> when the request is not authenticated</li>
  <li><code class="language-plaintext highlighter-rouge">403</code> when the user is authenticated but not allowed</li>
  <li><code class="language-plaintext highlighter-rouge">404</code> when the object should not be visible to that user</li>
  <li><code class="language-plaintext highlighter-rouge">413</code> when an upload is too large</li>
  <li><code class="language-plaintext highlighter-rouge">422</code> when the body has invalid client-controlled fields</li>
  <li><code class="language-plaintext highlighter-rouge">429</code> when the caller is over the rate limit</li>
</ul>

<p>If changing a JSON field gives me admin access, a cheaper plan, another user’s data, or extra paid API calls, the backend is trusting the frontend.</p>

<h2 id="cors-is-not-authorization">CORS is not authorization</h2>

<p>CORS is often the part that confuses people because it looks like an access control system.</p>

<p>For example:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">Access-Control-Allow-Origin: https://app.example.com
</span></code></pre></div></div>

<p>That header tells browsers whether frontend code from that app origin may read the response. It does not prove the caller is your app. It does not prove the user is allowed to perform the action.</p>

<p>Do not write code like this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">origin</span> <span class="o">=</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">origin</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">origin</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">https://app.example.com</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">403</span><span class="p">).</span><span class="nx">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Forbidden</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">}</span>

<span class="k">await</span> <span class="nx">deleteProject</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">projectID</span><span class="p">)</span>
</code></pre></div></div>

<p>That blocks some browser-based abuse. It is not enough.</p>

<p>Write the boring checks:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">requireUser</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span>
<span class="kd">const</span> <span class="nx">project</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getProject</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">projectID</span><span class="p">)</span>

<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">project</span> <span class="o">||</span> <span class="nx">project</span><span class="p">.</span><span class="nx">accountID</span> <span class="o">!==</span> <span class="nx">user</span><span class="p">.</span><span class="nx">accountID</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="nx">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Not found</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">}</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">role</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">admin</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">403</span><span class="p">).</span><span class="nx">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Forbidden</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">}</span>

<span class="k">await</span> <span class="nx">deleteProject</span><span class="p">(</span><span class="nx">project</span><span class="p">.</span><span class="nx">id</span><span class="p">)</span>
</code></pre></div></div>

<p>The exact framework does not matter. The rule does: authenticate the user, load the server-side record, check ownership and permissions, then do the work.</p>

<p><a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/">OWASP calls this Broken Object Level Authorization</a> when APIs let callers access objects by changing IDs. It is still one of the easiest backend bugs to create because the happy path works perfectly.</p>

<h2 id="do-not-trust-client-owned-fields">Do not trust client-owned fields</h2>

<p>The fastest way to create a backend bug is to accept the whole request body and pass it into your database update.</p>

<p>Avoid this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">db</span><span class="p">.</span><span class="nx">user</span><span class="p">.</span><span class="nx">update</span><span class="p">({</span>
  <span class="na">where</span><span class="p">:</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">id</span> <span class="p">},</span>
  <span class="na">data</span><span class="p">:</span> <span class="nx">request</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div></div>

<p>The UI may only send this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Hwee-Boon"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>but an attacker can send this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Hwee-Boon"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"role"</span><span class="p">:</span><span class="w"> </span><span class="s2">"admin"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"billingPlan"</span><span class="p">:</span><span class="w"> </span><span class="s2">"enterprise"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"emailVerified"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Use an allowlist:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">body</span> <span class="o">=</span> <span class="nx">updateProfileSchema</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span>

<span class="k">await</span> <span class="nx">db</span><span class="p">.</span><span class="nx">user</span><span class="p">.</span><span class="nx">update</span><span class="p">({</span>
  <span class="na">where</span><span class="p">:</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">id</span> <span class="p">},</span>
  <span class="na">data</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">displayName</span><span class="p">:</span> <span class="nx">body</span><span class="p">.</span><span class="nx">displayName</span><span class="p">,</span>
    <span class="na">avatarURL</span><span class="p">:</span> <span class="nx">body</span><span class="p">.</span><span class="nx">avatarURL</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">})</span>
</code></pre></div></div>

<p>This applies to reads too. Do not return a giant object and rely on the frontend to hide sensitive fields. Return the fields the current user is allowed to see.</p>

<p><a href="https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/">OWASP’s Broken Object Property Level Authorization</a> covers this kind of overexposure and mass assignment problem.</p>

<h2 id="csrf-is-the-browser-being-too-helpful">CSRF is the browser being too helpful</h2>

<p>Cookies make this more subtle.</p>

<p>If your app uses cookie-based sessions, the browser may attach those cookies automatically when it sends a request to your site. Your app gets simpler. It is also why <a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html">CSRF</a> exists: another site can try to make the user’s browser send an unwanted request to your site while the user is logged in.</p>

<p>Same-origin policy and CORS are not a full CSRF defense. They mainly affect whether malicious frontend code can read responses or send certain request types. You still need backend protection for state-changing routes.</p>

<p>For cookie-authenticated apps, I usually want:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">SameSite=Lax</code> or stricter cookies unless the app genuinely needs cross-site cookies</li>
  <li>CSRF tokens for state-changing form or API requests</li>
  <li><code class="language-plaintext highlighter-rouge">Origin</code> or <code class="language-plaintext highlighter-rouge">Referer</code> checks as a backup signal</li>
  <li>no state changes on <code class="language-plaintext highlighter-rouge">GET</code></li>
</ul>

<p>Do not treat <code class="language-plaintext highlighter-rouge">Origin</code> as identity. Treat it as one CSRF signal for browser requests. A non-browser client can send its own <code class="language-plaintext highlighter-rouge">Origin</code> header.</p>

<h2 id="some-browser-headers-are-useful-signals-not-identity">Some browser headers are useful signals, not identity</h2>

<p>Modern browsers also send fetch metadata headers such as <code class="language-plaintext highlighter-rouge">Sec-Fetch-Site</code> and <code class="language-plaintext highlighter-rouge">Sec-Fetch-Mode</code> in many requests. MDN notes that these are <a href="https://developer.mozilla.org/en-US/docs/Glossary/Fetch_metadata_request_header">forbidden request headers</a>, so frontend JavaScript cannot set them directly.</p>

<p>That makes them useful for rejecting suspicious browser-originated cross-site requests.</p>

<p>It does not turn them into authentication. A server-side script can still send whatever text it wants over HTTP. Your backend should use these headers as an extra browser-request filter, not as a replacement for auth, permissions, CSRF protection, and rate limits.</p>

<h2 id="the-checklist-i-use">The checklist I use</h2>

<p>For each backend route, I want a clear answer to these:</p>

<ul>
  <li>Who is the current user?</li>
  <li>Which account, tenant, team, or workspace does this request operate on?</li>
  <li>Is the current user allowed to do this action on this exact object?</li>
  <li>Which request body fields are allowed from the client?</li>
  <li>Which fields are server-owned and must be ignored or rejected?</li>
  <li>What limit applies to this operation?</li>
  <li>What happens if the request is replayed 20 times?</li>
  <li>What does the endpoint return if the object belongs to someone else?</li>
</ul>

<p>This is the prompt I would give a coding agent:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Review the backend API routes for places where the backend trusts the frontend.

For each route, check:
- missing authentication
- missing authorization
- object IDs not scoped to the current user/account
- request body fields that can overwrite server-owned fields
- expensive operations without server-side limits
- cookie-authenticated state changes without CSRF protection
- CORS or Origin checks being used as the main security check

Return concrete file paths, route names, exploit examples, and proposed fixes.
</code></pre></div></div>

<p>Then I would ask it to write direct request tests for the risky routes. A Playwright test through the UI is not enough for this. I want tests that hit the backend route with bad payloads and prove the server rejects them.</p>

<p>The browser is useful. Users get a safer place to run untrusted web pages. Your app gets useful signals. Plenty of dumb mistakes get blocked.</p>

<p>It is not guarding your backend.</p>]]></content><author><name></name></author><category term="Web" /><category term="Techniques" /><summary type="html"><![CDATA[Browsers enforce useful security rules, but your backend still has to authenticate, authorize, validate, rate limit, and reject direct API calls.]]></summary></entry><entry><title type="html">Coding Agent Skills vs Slash Commands, MCP, and CLI Tools</title><link href="https://hboon.com/coding-agent-skills-vs-slash-commands-mcp-and-cli-tools/" rel="alternate" type="text/html" title="Coding Agent Skills vs Slash Commands, MCP, and CLI Tools" /><published>2026-05-29T01:36:00+00:00</published><updated>2026-06-17T00:00:00+00:00</updated><id>https://hboon.com/coding-agent-skills-vs-slash-commands-mcp-and-cli-tools</id><content type="html" xml:base="https://hboon.com/coding-agent-skills-vs-slash-commands-mcp-and-cli-tools/"><![CDATA[<p>I use coding-agent skills a lot, but not every workflow should become a skill.</p>

<p>The categories I use are simple:</p>

<ul>
  <li>slash commands for short in-agent triggers</li>
  <li>skills for repeatable workflows that need judgment</li>
  <li>MCP for structured external context and tool access</li>
  <li>CLI scripts for deterministic automation</li>
</ul>

<p>Agents get worse when every tool is described as every other tool.</p>

<h2 id="slash-commands-are-triggers">Slash commands are triggers</h2>

<p>Slash commands are good for short, local interactions inside one agent.</p>

<p>Examples:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/review
/commit
/spec
/clear
</code></pre></div></div>

<p>They are convenient and fast. Usually they are tied to the agent you are using.</p>

<p>I do not use slash commands as the long-term place for detailed workflow knowledge. They are better as short triggers.</p>

<p>If <code class="language-plaintext highlighter-rouge">/commit</code> means “inspect dirty files, run formatters, choose an atomic commit message, avoid unrelated files, and use this repo’s commit style”, then the actual instructions belong somewhere more portable.</p>

<p>I put that in a skill.</p>

<h2 id="skills-are-workflows-with-judgment">Skills are workflows with judgment</h2>

<p>A skill is a markdown file that tells the agent how to do a repeatable task.</p>

<p>I use skills for workflows like:</p>

<ul>
  <li>commit succinctly</li>
  <li>review dirty changes</li>
  <li>review and fix until clean</li>
  <li>deploy with project-specific rules</li>
  <li>export logs from Better Stack</li>
  <li>set up Postmark</li>
  <li>create or improve other skills</li>
</ul>

<p>Each one needs more than a single shell command.</p>

<p>The agent has to inspect state, choose the right command, avoid certain files, handle errors, and report back. Skills work well for that.</p>

<p>A good skill says:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Use this when the user says "commit".

Steps:
<span class="p">-</span> inspect <span class="sb">`git status`</span>
<span class="p">-</span> review the diff
<span class="p">-</span> run the smallest relevant checks
<span class="p">-</span> stage only intended files
<span class="p">-</span> commit with the repo's commit style
<span class="p">-</span> report the commit hash

Guardrails:
<span class="p">-</span> do not include unrelated user changes
<span class="p">-</span> do not rewrite history
<span class="p">-</span> do not commit secrets
</code></pre></div></div>

<p>That gives the agent a small operating procedure.</p>

<h2 id="mcp-is-for-external-context-and-actions-not-process-notes">MCP is for external context and actions, not process notes</h2>

<p>MCP is useful when the agent needs a structured interface to something outside the repo.</p>

<p>Good MCP uses:</p>

<ul>
  <li>query a database through a controlled interface</li>
  <li>inspect a browser page</li>
  <li>read documents from a service</li>
  <li>call an internal API with typed parameters</li>
  <li>expose app-specific actions to the agent</li>
</ul>

<p>I do not use MCP just to hold instructions.</p>

<p>If the task is “when deploying, use Kamal in tmux and add an Agent Control callback”, that belongs in <code class="language-plaintext highlighter-rouge">AGENTS.md</code> or a deploy skill.</p>

<p>If the task is “query production logs with a time range, service name, and structured filters”, MCP might make sense. The agent gets a tool with parameters instead of hand-assembling curl commands every time.</p>

<p>MCP also has prompts and resources. I still treat those as interfaces to a running server or external context, not as the place to keep my personal repo rules.</p>

<p>My rule: if the agent needs structured context or an action exposed by another system, use MCP. If the agent needs a procedure, use a skill.</p>

<h2 id="cli-scripts-are-for-deterministic-work">CLI scripts are for deterministic work</h2>

<p>Some workflows should be plain scripts.</p>

<p>If the steps are deterministic and do not need model judgment, I write a CLI script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bun scripts/generate-sitemap.ts
bun scripts/check-links.ts
bun scripts/export-report.ts <span class="nt">--from</span> 2026-05-01 <span class="nt">--to</span> 2026-05-29
</code></pre></div></div>

<p>The agent can still run the script. I want the branching and edge cases in code.</p>

<p>I use this pattern for checks:</p>

<ul>
  <li>validate frontmatter</li>
  <li>find broken links</li>
  <li>generate reports</li>
  <li>export analytics</li>
  <li>normalize files</li>
  <li>submit URLs</li>
</ul>

<p>The script can fail loudly and consistently. The agent then fixes the failure.</p>

<p>That is better than asking the agent to remember every edge case from a paragraph.</p>

<h2 id="how-i-decide">How I decide</h2>

<p>Here is the decision table I use:</p>

<table>
  <thead>
    <tr>
      <th>Need</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Short trigger inside one agent</td>
      <td>Slash command</td>
    </tr>
    <tr>
      <td>Repeatable workflow with judgment</td>
      <td>Skill</td>
    </tr>
    <tr>
      <td>Structured external context or actions</td>
      <td>MCP</td>
    </tr>
    <tr>
      <td>Deterministic automation</td>
      <td>CLI script</td>
    </tr>
    <tr>
      <td>Standing repo rule</td>
      <td><code class="language-plaintext highlighter-rouge">AGENTS.md</code></td>
    </tr>
  </tbody>
</table>

<p>Most workflows I care about use more than one.</p>

<p>For example, my deploy flow has:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">AGENTS.md</code> for the standing rule: deploys run in tmux</li>
  <li>a skill for the project deploy workflow</li>
  <li>Kamal as the CLI tool</li>
  <li>Agent Control as the callback channel</li>
  <li>shell commands for verification</li>
</ul>

<p>The skill coordinates the work. Kamal does the deterministic deploy. Repo instructions keep the agent from guessing.</p>

<h2 id="examples">Examples</h2>

<h3 id="commit">Commit</h3>

<p>Commit looks like a slash command. In practice, I want more than <code class="language-plaintext highlighter-rouge">git commit</code>.</p>

<p>The easy version:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git add <span class="nb">.</span>
git commit <span class="nt">-m</span> <span class="s2">"Update"</span>
</code></pre></div></div>

<p>The version I want:</p>

<ul>
  <li>inspect dirty files</li>
  <li>separate my changes from unrelated user changes</li>
  <li>run focused checks</li>
  <li>stage only intended files</li>
  <li>use the repo’s commit style</li>
  <li>avoid generated files unless they are expected</li>
  <li>report what was committed</li>
</ul>

<p>That is a skill.</p>

<p>The skill may still run normal CLI commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git status <span class="nt">--short</span>
git diff
git add path/to/file
git commit <span class="nt">-m</span> <span class="s2">"Write"</span>
</code></pre></div></div>

<p>The judgment around those commands is the useful part.</p>

<h3 id="external-logs">External logs</h3>

<p>Log export is different.</p>

<p>Part of it is process:</p>

<ul>
  <li>know which Better Stack cluster a project uses</li>
  <li>know where credentials are stored</li>
  <li>know when UI export is faster than API export</li>
</ul>

<p>Part of it is structured access:</p>

<ul>
  <li>query logs by time range</li>
  <li>filter by service</li>
  <li>extract JSON fields</li>
  <li>return rows consistently</li>
</ul>

<p>That can start as a skill. If I run it often enough, the query part should become a script or MCP tool.</p>

<p>I do not need to decide upfront. I usually start with a skill because it captures what I learned while doing the task. Then I move deterministic parts into code.</p>

<h3 id="deployment">Deployment</h3>

<p>Deployment uses the same split.</p>

<p>My deploy workflow has judgment:</p>

<ul>
  <li>is this a frontend-only change?</li>
  <li>should the worker be restarted?</li>
  <li>did the migration run?</li>
  <li>did the health check fail because of code, config, or an external service?</li>
  <li>should the deploy be rolled back?</li>
</ul>

<p>That belongs in instructions and skills.</p>

<p>The actual deploy command is still a CLI:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kamal deploy
</code></pre></div></div>

<p>I want the agent to follow the project workflow: run the command, read the output, and know when to stop.</p>

<p>If the deploy is long-running, my repo instructions say to run it in tmux and send an Agent Control callback when it finishes. That rule lives in <code class="language-plaintext highlighter-rouge">AGENTS.md</code> because it applies across deploy tasks.</p>

<p>The skill can then focus on the sequence:</p>

<ul>
  <li>check dirty files</li>
  <li>run the expected checks</li>
  <li>start the deploy in the right place</li>
  <li>wait for the callback</li>
  <li>verify the app</li>
  <li>report the result</li>
</ul>

<p>Kamal does the deployment. The skill handles the workflow around it.</p>

<h3 id="what-stays-in-agentsmd">What stays in AGENTS.md</h3>

<p>I keep standing rules in <code class="language-plaintext highlighter-rouge">AGENTS.md</code>.</p>

<p>Examples:</p>

<ul>
  <li>use <code class="language-plaintext highlighter-rouge">pnpm</code> for installs</li>
  <li>run scripts with <code class="language-plaintext highlighter-rouge">bun run</code></li>
  <li>do not start dev servers unless needed</li>
  <li>deploys run in tmux</li>
  <li>when I say “commit”, use the commit skill</li>
  <li>when I say “take over”, run the take-over workflow</li>
</ul>

<p>Those rules should be loaded every session. They change how the agent behaves across the repo.</p>

<p>I do not put a full deploy runbook or log-export guide in <code class="language-plaintext highlighter-rouge">AGENTS.md</code>. That turns the file into a junk drawer.</p>

<p>The top-level file should route the agent:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">-</span> When I say "commit", use <span class="sb">`commit-succinct`</span>.
<span class="p">-</span> When I say "deploy", use the project deploy skill.
<span class="p">-</span> Better Stack log exports use the <span class="sb">`betterstack-log-export`</span> skill.
</code></pre></div></div>

<p>The detailed workflow goes in the skill. Deterministic work goes in code. Repo rules stay short.</p>

<h3 id="what-becomes-code">What becomes code</h3>

<p>When a skill grows a long checklist with no judgment, I usually turn that part into a script.</p>

<p>For example, this should be code:</p>

<ul>
  <li>check whether every public route has prerendered output</li>
  <li>validate sitemap URLs</li>
  <li>detect missing env vars in a template</li>
  <li>export a report for a fixed date range</li>
  <li>normalize frontmatter</li>
</ul>

<p>The script gives the agent a pass/fail result.</p>

<p>That is better than a paragraph that says “make sure the sitemap is correct.” The agent can eyeball the file and miss a URL.</p>

<p>With a script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bun run check:seo
</code></pre></div></div>

<p>The agent gets a real failure. Then it can fix the source and rerun the check.</p>

<p>I use the same pattern for content work.</p>

<p>The writing guidance belongs in a skill because it needs judgment. The mechanical checks belong in tools. For these posts, <code class="language-plaintext highlighter-rouge">writing-voice</code> gives the style rules, and <code class="language-plaintext highlighter-rouge">prosesmasher</code> catches repeated cadence, boilerplate, and article-structure problems.</p>

<p>Neither one replaces reading the draft. The skill tells the agent what good looks like. The checker catches things that are easy to miss when editing quickly.</p>

<p>That combination is better than telling the agent “make it sound human.” Give it the voice rules, run the checker, edit the file, and run the checker again.</p>

<p>The same pattern works for code too. I put taste and judgment in instructions, then put repeatable verification in commands. The agent has to use both before it says the work is ready to ship safely.</p>

<h2 id="why-i-put-this-in-starter-kits">Why I put this in starter kits</h2>

<p>A SaaS starter kit should include a way to work on the code.</p>

<p>That means:</p>

<ul>
  <li>repo instructions for defaults</li>
  <li>skills for workflows</li>
  <li>scripts for checks and generation</li>
  <li>deployment commands that agents can run</li>
  <li>docs that explain where each piece belongs</li>
</ul>

<p><a href="https://stacknaut.com/docs/ai-agent-config">Stacknaut</a> includes agent configuration because this is how I build production apps now. If you are starting from a blank repo, the <a href="https://stacknaut.com/tools/agents-md-generator">AGENTS.md generator</a> gives you a first draft before you split repeated workflows into skills.</p>

<p>The app code is not enough. The workflow around the app has to be there too.</p>

<p>Without it, every new agent session starts by guessing.</p>]]></content><author><name></name></author><category term="AI" /><category term="Tools" /><summary type="html"><![CDATA[How I decide whether a coding-agent workflow belongs in a skill, slash command, MCP server, or plain CLI script.]]></summary></entry><entry><title type="html">Vite SPA Static SEO Meta Tags Without SSR</title><link href="https://hboon.com/vite-spa-static-seo-meta-tags-without-ssr/" rel="alternate" type="text/html" title="Vite SPA Static SEO Meta Tags Without SSR" /><published>2026-05-29T01:24:00+00:00</published><updated>2026-05-29T01:24:00+00:00</updated><id>https://hboon.com/vite-spa-static-seo-meta-tags-without-ssr</id><content type="html" xml:base="https://hboon.com/vite-spa-static-seo-meta-tags-without-ssr/"><![CDATA[<p>I like Vue SPAs for SaaS apps. The app is mostly behind login, and the marketing pages are usually the only SEO-sensitive pages.</p>

<p>That does not mean I want SSR.</p>

<p>For my use case, Vite static output plus prerendered HTML is enough: generate real HTML for public routes at build time, write the SEO tags into each page, and serve static files.</p>

<h2 id="the-problem-with-a-plain-spa">The problem with a plain SPA</h2>

<p>A plain Vite SPA usually ships one <code class="language-plaintext highlighter-rouge">index.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"app"</span><span class="nt">&gt;&lt;/div&gt;</span>
<span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"module"</span> <span class="na">src=</span><span class="s">"/src/main.ts"</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p>Vue Router handles the route after JavaScript loads.</p>

<p>A plain SPA is fine for an app dashboard. It is not enough for public pages where I care about search previews, canonical URLs, and content being visible in the initial HTML.</p>

<p>For SEO pages, I want this in the static output:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;title&gt;</span>Kamal vs Coolify for Solo SaaS Deployment<span class="nt">&lt;/title&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">content=</span><span class="s">"A practical comparison of Kamal and Coolify..."</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"canonical"</span> <span class="na">href=</span><span class="s">"https://example.com/kamal-vs-coolify/"</span> <span class="nt">/&gt;</span>

<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"Kamal vs Coolify for Solo SaaS Deployment"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:description"</span> <span class="na">content=</span><span class="s">"A practical comparison of Kamal and Coolify..."</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"https://example.com/kamal-vs-coolify/"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>And I want the page body rendered too.</p>

<p>No need to make the server render every request if the content changes when I deploy anyway.</p>

<h2 id="keep-the-route-data-in-one-place">Keep the route data in one place</h2>

<p>The first step is to stop scattering SEO data across components.</p>

<p>I keep public route metadata in a structured list:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">publicPages</span> <span class="o">=</span> <span class="p">[</span>
  <span class="p">{</span>
    <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/kamal-vs-coolify/</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Kamal vs Coolify for Solo SaaS Deployment</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">description</span><span class="p">:</span>
      <span class="dl">"</span><span class="s2">A practical comparison of Kamal and Coolify for solo SaaS apps.</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">ogImage</span><span class="p">:</span> <span class="dl">"</span><span class="s2">https://example.com/og/kamal-vs-coolify.png</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">},</span>
  <span class="p">{</span>
    <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/vue-prerendering-without-ssr/</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Vue Prerendering Without SSR</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">description</span><span class="p">:</span>
      <span class="dl">"</span><span class="s2">How to prerender Vue marketing pages at build time without moving to SSR.</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">ogImage</span><span class="p">:</span> <span class="dl">"</span><span class="s2">https://example.com/og/vue-prerendering.png</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">]</span> <span class="k">as</span> <span class="kd">const</span>
</code></pre></div></div>

<p>Vue Router can use the same data:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">routes</span> <span class="o">=</span> <span class="nx">publicPages</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">page</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
  <span class="na">path</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span>
  <span class="na">component</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./pages/MarketingPage.vue</span><span class="dl">"</span><span class="p">),</span>
  <span class="na">meta</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">title</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">title</span><span class="p">,</span>
    <span class="na">description</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">description</span><span class="p">,</span>
    <span class="na">ogImage</span><span class="p">:</span> <span class="nx">page</span><span class="p">.</span><span class="nx">ogImage</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">}))</span>
</code></pre></div></div>

<p>The prerender script can use it too.</p>

<p>That avoids the usual mess where the browser title says one thing, the sitemap says another, and the generated HTML has an old description.</p>

<h2 id="the-prerender-script-writes-html">The prerender script writes HTML</h2>

<p>I usually keep this as a small build step after Vite builds the client bundle.</p>

<p>The rough flow:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bun run build
bun run prerender
</code></pre></div></div>

<p>The prerender script:</p>

<ul>
  <li>reads the route list</li>
  <li>loads the built <code class="language-plaintext highlighter-rouge">dist/index.html</code></li>
  <li>renders or injects page HTML for each public route</li>
  <li>writes <code class="language-plaintext highlighter-rouge">dist/&lt;route&gt;/index.html</code></li>
  <li>replaces the SEO tags per route</li>
  <li>writes or updates <code class="language-plaintext highlighter-rouge">sitemap.xml</code></li>
</ul>

<p>The meta tag replacement can stay boring:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">escapeHTML</span><span class="p">(</span><span class="nx">value</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">value</span>
    <span class="p">.</span><span class="nx">replaceAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">&amp;</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">&amp;amp;</span><span class="dl">"</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">replaceAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">&lt;</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">&amp;lt;</span><span class="dl">"</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">replaceAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">&gt;</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">&amp;gt;</span><span class="dl">"</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">replaceAll</span><span class="p">(</span><span class="dl">'</span><span class="s1">"</span><span class="dl">'</span><span class="p">,</span> <span class="dl">"</span><span class="s2">&amp;quot;</span><span class="dl">"</span><span class="p">)</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">withSeoTags</span><span class="p">(</span><span class="nx">html</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">page</span><span class="p">:</span> <span class="nx">PublicPage</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">canonicalURL</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span> <span class="dl">"</span><span class="s2">https://example.com</span><span class="dl">"</span><span class="p">).</span><span class="nx">toString</span><span class="p">()</span>
  <span class="kd">const</span> <span class="nx">title</span> <span class="o">=</span> <span class="nx">escapeHTML</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">title</span><span class="p">)</span>
  <span class="kd">const</span> <span class="nx">description</span> <span class="o">=</span> <span class="nx">escapeHTML</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">description</span><span class="p">)</span>

  <span class="k">return</span> <span class="nx">html</span>
    <span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/&lt;title&gt;.*</span><span class="se">?</span><span class="sr">&lt;</span><span class="se">\/</span><span class="sr">title&gt;/</span><span class="p">,</span> <span class="s2">`&lt;title&gt;</span><span class="p">${</span><span class="nx">title</span><span class="p">}</span><span class="s2">&lt;/title&gt;`</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">replace</span><span class="p">(</span>
      <span class="sr">/&lt;meta name="description" content=".*</span><span class="se">?</span><span class="sr">"&gt;/</span><span class="p">,</span>
      <span class="s2">`&lt;meta name="description" content="</span><span class="p">${</span><span class="nx">description</span><span class="p">}</span><span class="s2">"&gt;`</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="p">.</span><span class="nx">replace</span><span class="p">(</span>
      <span class="dl">"</span><span class="s2">&lt;/head&gt;</span><span class="dl">"</span><span class="p">,</span>
      <span class="p">[</span>
        <span class="s2">`&lt;link rel="canonical" href="</span><span class="p">${</span><span class="nx">canonicalURL</span><span class="p">}</span><span class="s2">"&gt;`</span><span class="p">,</span>
        <span class="s2">`&lt;meta property="og:title" content="</span><span class="p">${</span><span class="nx">title</span><span class="p">}</span><span class="s2">"&gt;`</span><span class="p">,</span>
        <span class="s2">`&lt;meta property="og:description" content="</span><span class="p">${</span><span class="nx">description</span><span class="p">}</span><span class="s2">"&gt;`</span><span class="p">,</span>
        <span class="s2">`&lt;meta property="og:url" content="</span><span class="p">${</span><span class="nx">canonicalURL</span><span class="p">}</span><span class="s2">"&gt;`</span><span class="p">,</span>
        <span class="s2">`&lt;meta property="og:image" content="</span><span class="p">${</span><span class="nx">page</span><span class="p">.</span><span class="nx">ogImage</span><span class="p">}</span><span class="s2">"&gt;`</span><span class="p">,</span>
        <span class="dl">"</span><span class="s2">&lt;/head&gt;</span><span class="dl">"</span><span class="p">,</span>
      <span class="p">].</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="dl">"</span><span class="p">),</span>
    <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>You can use an HTML parser if you want. The important part is simpler: every SEO route gets its own static HTML file.</p>

<h3 id="do-not-rely-on-the-browser-title">Do not rely on the browser title</h3>

<p>It is tempting to set all of this from Vue Router:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">router</span><span class="p">.</span><span class="nx">afterEach</span><span class="p">((</span><span class="nx">to</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">title</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="nx">to</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">title</span><span class="p">)</span>
<span class="p">})</span>
</code></pre></div></div>

<p>I still do that for the running app. It is useful when users navigate between pages after JavaScript loads.</p>

<p>But it does not solve static SEO output. The generated HTML still needs the title and meta tags before the browser runs the app.</p>

<p>So I treat router metadata as input, not the whole solution. The prerender step writes the final tags into each <code class="language-plaintext highlighter-rouge">index.html</code>.</p>

<p>The browser can update the title later. Google can render JavaScript, but that still adds another step, and not every crawler or link preview bot runs JavaScript. I do not want the important tags to depend on that.</p>

<h2 id="add-a-check-so-pages-do-not-silently-regress">Add a check so pages do not silently regress</h2>

<p>The easiest mistake is adding a new public route and forgetting to add it to the prerender list.</p>

<p>I like a small script that fails the build when a public SEO route is missing static output:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">existsSync</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">node:fs</span><span class="dl">"</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">join</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">node:path</span><span class="dl">"</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">publicPages</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">../src/publicPages</span><span class="dl">"</span>

<span class="kd">const</span> <span class="nx">missing</span> <span class="o">=</span> <span class="nx">publicPages</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">page</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">return</span> <span class="o">!</span><span class="nx">existsSync</span><span class="p">(</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2">dist</span><span class="dl">"</span><span class="p">,</span> <span class="nx">page</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span> <span class="dl">"</span><span class="s2">index.html</span><span class="dl">"</span><span class="p">))</span>
<span class="p">})</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">missing</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span>
    <span class="nx">missing</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">page</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">`Missing prerender output: </span><span class="p">${</span><span class="nx">page</span><span class="p">.</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="dl">"</span><span class="p">),</span>
  <span class="p">)</span>
  <span class="nx">process</span><span class="p">.</span><span class="nx">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then the build flow becomes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bun run build
bun run prerender
bun run check:prerender
</code></pre></div></div>

<p>The check is especially useful with coding agents. If the agent adds <code class="language-plaintext highlighter-rouge">/compare/kamal-vs-coolify/</code> but forgets to include it in the prerender list, the script catches it.</p>

<p>I would rather have a boring build failure than discover the problem in Search Console two weeks later.</p>

<h2 id="canonical-urls-matter">Canonical URLs matter</h2>

<p>SPAs make it easy to accidentally serve the same content from multiple URLs:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/pricing</code></li>
  <li><code class="language-plaintext highlighter-rouge">/pricing/</code></li>
  <li><code class="language-plaintext highlighter-rouge">/index.html</code></li>
  <li><code class="language-plaintext highlighter-rouge">/?route=pricing</code></li>
</ul>

<p>Pick one.</p>

<p>I prefer trailing slashes for static routes because they map cleanly to directories:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dist/pricing/index.html
dist/kamal-vs-coolify/index.html
</code></pre></div></div>

<p>Then the canonical URL should match exactly:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"canonical"</span> <span class="na">href=</span><span class="s">"https://example.com/pricing/"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>The sitemap should use the same URL. Internal links should use the same URL. Do not make Google decide which version you meant.</p>

<h3 id="what-goes-into-the-sitemap">What goes into the sitemap</h3>

<p>For static marketing routes, the sitemap should come from the same page list:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">urls</span> <span class="o">=</span> <span class="nx">publicPages</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">page</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">loc</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span> <span class="dl">"</span><span class="s2">https://example.com</span><span class="dl">"</span><span class="p">).</span><span class="nx">toString</span><span class="p">()</span>
  <span class="k">return</span> <span class="s2">`&lt;url&gt;&lt;loc&gt;</span><span class="p">${</span><span class="nx">loc</span><span class="p">}</span><span class="s2">&lt;/loc&gt;&lt;/url&gt;`</span>
<span class="p">})</span>
</code></pre></div></div>

<p>I do not want a second list of URLs just for <code class="language-plaintext highlighter-rouge">sitemap.xml</code>.</p>

<p>The practical rule:</p>

<ul>
  <li>if a public route should rank, it belongs in <code class="language-plaintext highlighter-rouge">publicPages</code></li>
  <li>if it belongs in <code class="language-plaintext highlighter-rouge">publicPages</code>, it gets prerendered</li>
  <li>if it gets prerendered, it goes in the sitemap</li>
  <li>if it should not rank, mark it <code class="language-plaintext highlighter-rouge">noindex</code> or keep it out</li>
  <li>if it has internal links, use normal <code class="language-plaintext highlighter-rouge">&lt;a href="..."&gt;</code> links, not hash routes</li>
</ul>

<p>That keeps the SEO surface small enough to reason about.</p>

<h2 id="when-this-is-enough">When this is enough</h2>

<p>Build-time prerendering works well for:</p>

<ul>
  <li>landing pages</li>
  <li>pricing pages</li>
  <li>docs pages</li>
  <li>comparison pages</li>
  <li>changelog pages</li>
  <li>mostly static articles</li>
</ul>

<p>It is less useful for pages where the HTML needs to change per request:</p>

<ul>
  <li>user profiles that update constantly</li>
  <li>pages personalized by auth state</li>
  <li>inventory pages with minute-by-minute changes</li>
  <li>search results</li>
</ul>

<p>For those, SSR or server-generated pages may be worth it.</p>

<p>Most SaaS marketing sites do not need that. They need stable public pages with good titles, descriptions, canonical URLs, and Open Graph previews.</p>

<h2 id="why-i-use-this-in-saas-starter-kits">Why I use this in SaaS starter kits</h2>

<p><a href="https://stacknaut.com/articles/vue-prerendering-without-ssr">Stacknaut</a> uses Vue because I like the productivity of Vue for app work. I do not want to give up that app structure just because the marketing site needs SEO.</p>

<p>The setup I like:</p>

<ul>
  <li>Vue stays Vue</li>
  <li>Vite still outputs static files</li>
  <li>public pages get real HTML</li>
  <li>SEO tags are generated from route data</li>
  <li>deployment stays simple</li>
</ul>

<p>I get static output that search engines and social previews can read, without running an SSR server or moving to another framework.</p>

<h3 id="where-this-breaks">Where this breaks</h3>

<p>The weak spot is dynamic content.</p>

<p>If a page changes every few minutes, prerendering at deploy time is the wrong tool. You either need server-rendered HTML, a separate static regeneration job, or a page that is not trying to rank.</p>

<p>I also avoid prerendering anything that depends on auth state. A logged-in dashboard should not be in the sitemap. The HTML can be empty until the app loads because search traffic is not the point of that page.</p>

<p>That split keeps the architecture simple:</p>

<ul>
  <li>public marketing pages get prerendered HTML</li>
  <li>logged-in app pages stay normal SPA routes</li>
  <li>dynamic SEO pages need a separate decision</li>
</ul>

<p>Most of my SaaS work fits that split.</p>

<p>I also keep the app shell separate from the marketing pages mentally. The app can have loading states, auth forwarding, and client-only data fetching. The marketing pages need stable text, stable URLs, and stable previews.</p>

<p>The two sides have different jobs. Trying to make one route mode handle everything is how a simple SPA turns into a half-SSR framework project by accident.</p>

<p>If I need that later, fine. I would rather start with static output and add server rendering only when a page proves it needs it.</p>

<p>The other place this can break is URL forwarding.</p>

<p>If <code class="language-plaintext highlighter-rouge">/pricing</code> forwards to <code class="language-plaintext highlighter-rouge">/pricing/</code>, make the canonical URL <code class="language-plaintext highlighter-rouge">/pricing/</code> and put <code class="language-plaintext highlighter-rouge">/pricing/</code> in the sitemap. Do not mix both forms across nav links, generated pages, and marketing copy.</p>

<p>I also like checking the generated output directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rg <span class="nt">-n</span> <span class="s2">"&lt;title&gt;|canonical|og:title|description"</span> dist/pricing/index.html
</code></pre></div></div>

<p>For a small site, that one command catches most mistakes. If the title is generic, the canonical is wrong, or the description is missing, I can fix the page metadata before deploy.</p>

<p>For a larger site, I turn that into a script and make the build fail. The rule is the same either way: inspect the static HTML that ships to production.</p>

<p>That is the whole point of prerendering. The output file is the thing search engines and preview bots see first.</p>]]></content><author><name></name></author><category term="Web" /><category term="Techniques" /><summary type="html"><![CDATA[How I handle static SEO meta tags, canonical URLs, Open Graph tags, and prerendered HTML for Vite and Vue SPAs without adding SSR.]]></summary></entry></feed>