<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: frnxcode</title>
    <description>The latest articles on DEV Community by frnxcode (@frnxcode).</description>
    <link>https://dev.to/frnxcode</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4087179%2F9b401996-bb1f-411f-9b6a-eec6d9116a1c.png</url>
      <title>DEV Community: frnxcode</title>
      <link>https://dev.to/frnxcode</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/frnxcode"/>
    <language>en</language>
    <item>
      <title>The trailing newline that broke every webhook signature.</title>
      <dc:creator>frnxcode</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:31:33 +0000</pubDate>
      <link>https://dev.to/frnxcode/the-trailing-newline-that-broke-every-webhook-signature-3pgc</link>
      <guid>https://dev.to/frnxcode/the-trailing-newline-that-broke-every-webhook-signature-3pgc</guid>
      <description>&lt;p&gt;I spent an evening convinced GitHub was sending broken webhook signatures. It wasn't. echo was.&lt;/p&gt;

&lt;p&gt;The setup&lt;/p&gt;

&lt;p&gt;Chngd's GitHub Marketplace webhook verifies every inbound event with HMAC-SHA256, comparing GitHub's X-Hub-Signature-256 header against a signature computed from the raw request body and a shared secret.&lt;/p&gt;

&lt;p&gt;Every single request failed verification. Not intermittently. Every one, from the very first test delivery.&lt;/p&gt;

&lt;p&gt;Where I looked first&lt;/p&gt;

&lt;p&gt;The obvious suspects, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was I hashing the raw body, or a re-serialized/re-parsed version? Framework body-parsers are notorious for this: re-serializing JSON changes byte-for-byte content even when the data is "the same." Checked: raw body, untouched.&lt;/li&gt;
&lt;li&gt;Wrong hashing algorithm or encoding (hex vs base64)? Checked against GitHub's docs: correct.&lt;/li&gt;
&lt;li&gt;Timing-safe comparison bug? Checked: standard constant-time compare, no typo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All correct. And it still failed, every time, deterministically. That determinism should have been the tell. A subtle logic bug fails sometimes or on specific payloads, not literally always.&lt;/p&gt;

&lt;p&gt;Where it actually was&lt;/p&gt;

&lt;p&gt;The secret itself was wrong. Not the value. The bytes.&lt;/p&gt;

&lt;p&gt;I'd generated it and piped it into the environment file with echo $SECRET &amp;gt;&amp;gt; .env. echo appends a trailing newline by default. That newline became part of the stored secret string. GitHub's config field has no trailing newline, so their computed signature was based on the clean secret. My server was hashing with the secret plus an invisible \n, so every signature it computed was simply wrong, no matter what payload came through.&lt;/p&gt;

&lt;p&gt;Nothing in any error message hinted at this. A signature mismatch looks identical whether the payload is wrong, the algorithm is wrong, or the secret has one invisible extra byte at the end.&lt;/p&gt;

&lt;p&gt;The fix&lt;/p&gt;

&lt;p&gt;printf '%s' "$SECRET" &amp;gt;&amp;gt; .env instead of echo. printf doesn't add anything you didn't put there.&lt;/p&gt;

&lt;p&gt;The actual debugging move&lt;/p&gt;

&lt;p&gt;If you hit a signature mismatch, don't start by re-deriving your verification code. Print the secret's length and a repr/quoted-string dump of it server-side first. A trailing newline shows up immediately in a repr; it's invisible everywhere else. That one check would have saved most of the evening.&lt;/p&gt;

&lt;p&gt;The structural problem&lt;/p&gt;

&lt;p&gt;echo vs printf is the proximate cause, not the real one. The real one is that a secret transited a human shell session and a hand-edited file with no validation step. Two things follow from that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Secrets shouldn't be typed or piped through a terminal where avoidable. Generate them, inject via a secrets manager or CI/CD secret store, and verify the stored value (byte length, or a known-good HMAC against a test vector) before it ships.&lt;/li&gt;
&lt;li&gt;Appending to a .env file from a shell is fragile on its own terms, independent of this bug. If the variable already exists in the file, you now have two entries, and which one wins depends on parser load order, not intent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither of those is exotic. Both would have caught this before it ever reached "why is GitHub sending bad signatures."&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>github</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Building an AI-drafted changelog tool with a GitHub App and Claude, solo</title>
      <dc:creator>frnxcode</dc:creator>
      <pubDate>Thu, 20 Aug 2026 20:27:44 +0000</pubDate>
      <link>https://dev.to/frnxcode/building-an-ai-drafted-changelog-tool-with-a-github-app-and-claude-solo-424i</link>
      <guid>https://dev.to/frnxcode/building-an-ai-drafted-changelog-tool-with-a-github-app-and-claude-solo-424i</guid>
      <description>&lt;p&gt;I've been building a hosted changelog tool called Chngd, mostly solo, using Claude Code as a daily collaborator rather than an autocomplete tool. Here's what the actual build looked like, technically, and a mistake that turned into the most useful lesson so far.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core loop
&lt;/h2&gt;

&lt;p&gt;Most changelog tools ask you to write the changelog. Chngd tries to remove that step: connect a GitHub repo, and it drafts a customer-facing changelog entry from your recent commits and PRs. You review, edit, and publish. Nothing goes out automatically.&lt;/p&gt;

&lt;p&gt;The pipeline is simple on paper:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull commits since the last published entry&lt;/li&gt;
&lt;li&gt;Filter out anything that's clearly not customer-facing (&lt;code&gt;chore:&lt;/code&gt;, &lt;code&gt;ci:&lt;/code&gt;, &lt;code&gt;test:&lt;/code&gt;, &lt;code&gt;docs:&lt;/code&gt; prefixes, &lt;code&gt;[skip changelog]&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Send what's left to Claude with a system prompt telling it the audience is non-technical end users, not developers&lt;/li&gt;
&lt;li&gt;Get back structured JSON: a title and a markdown body&lt;/li&gt;
&lt;li&gt;Show it in a dashboard for editing before publish&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The filtering step matters more than I expected. Sending raw commit logs to a model produces a changelog that reads like a commit log with better grammar. Filtering internal-only commits first, before the model ever sees them, is what actually makes the output read like something a customer would want.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub App vs OAuth+PAT
&lt;/h2&gt;

&lt;p&gt;The lazier option was OAuth plus a personal access token. It's faster to build, maybe half a day faster. I went with a GitHub App instead, and the reason came down to one question: what does the paid tier actually need?&lt;/p&gt;

&lt;p&gt;The free tier is a manual "sync now" button. The paid tier auto-syncs on push, via webhook, and auto-drafts (never auto-publishes, that stays a human action). That's fundamentally a GitHub App feature, not an OAuth+PAT one, because per-repo webhooks and installation-scoped access are how GitHub Apps work. Building it on OAuth+PAT first would have meant asking every existing user to redo their connection later. Paying the extra setup cost upfront avoided a migration I'd have hated doing after launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake: Supabase RLS
&lt;/h2&gt;

&lt;p&gt;This is the part worth writing about honestly instead of skipping.&lt;/p&gt;

&lt;p&gt;I'm using Supabase for Postgres plus Auth, but all the actual data access happens server-side through Drizzle, not through Supabase's client libraries. Because of that, I treated Row-Level Security as something that applied to a pattern I wasn't using, and didn't think hard about it.&lt;/p&gt;

&lt;p&gt;That was wrong. Supabase's REST API (PostgREST) sits in front of every table regardless of how your own server code talks to the database. The public anon key is baked into client-side JS by design, it's meant to be public. Without RLS enabled, that anon key can read and write any table with no policy in place, completely independent of whatever access patterns your app code uses.&lt;/p&gt;

&lt;p&gt;Every table was exposed like this for weeks before I caught it, via a monitoring alert I'd nearly missed. I audited for tampering (none found), enabled RLS on all six tables, verified with both a direct Postgres query and a live curl test against the REST endpoint with the anon key, and set up Slack alerting so a future alert doesn't depend on me checking email.&lt;/p&gt;

&lt;p&gt;The lesson: RLS is off by default in Supabase, and "I don't use the client library for this" is not the same as "this table is protected." If you're using Supabase as Postgres+Auth with a server-side ORM, check this today. It costs one migration and a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Live at &lt;a href="https://chngd.dev" rel="noopener noreferrer"&gt;chngd.dev&lt;/a&gt;, test-mode-to-live Stripe billing, GitHub App install flow, embeddable widget, email notifications on publish. Built and shipped solo. Happy to answer questions about any of the above, especially the Claude integration or the GitHub App setup if anyone's weighing that same decision.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>github</category>
      <category>ai</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
