<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[With Receipts]]></title><description><![CDATA[Running AI coding agents on production code, solo. Every post is a real incident or a real gate, with receipts you can check.]]></description><link>https://erikhernandez.dev</link><image><url>https://substackcdn.com/image/fetch/$s_!C5M0!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F38c4e1b3-d099-4b5e-a307-dfbdacde216d_1024x1024.png</url><title>With Receipts</title><link>https://erikhernandez.dev</link></image><generator>Substack</generator><lastBuildDate>Wed, 19 Aug 2026 02:10:20 GMT</lastBuildDate><atom:link href="https://erikhernandez.dev/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Erik Hernandez]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[erikhernandez@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[erikhernandez@substack.com]]></itunes:email><itunes:name><![CDATA[Erik Hernandez]]></itunes:name></itunes:owner><itunes:author><![CDATA[Erik Hernandez]]></itunes:author><googleplay:owner><![CDATA[erikhernandez@substack.com]]></googleplay:owner><googleplay:email><![CDATA[erikhernandez@substack.com]]></googleplay:email><googleplay:author><![CDATA[Erik Hernandez]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[The query filter that matched everything]]></title><description><![CDATA[A missing property, a silently dropped filter, and the CI gates that now fail the build.]]></description><link>https://erikhernandez.dev/p/the-query-filter-that-matched-everything</link><guid isPermaLink="false">https://erikhernandez.dev/p/the-query-filter-that-matched-everything</guid><dc:creator><![CDATA[Erik Hernandez]]></dc:creator><pubDate>Tue, 18 Aug 2026 15:12:29 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!C5M0!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F38c4e1b3-d099-4b5e-a307-dfbdacde216d_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p></p><p>I clicked the export-all button on my own dashboard, the one that downloads everything you have entered, and the file contained rows that did not belong to the account I was logged into. Every income, every expense, every budget and debt in the database, in one download, for whoever was logged in.</p><p>I had shipped that export feature at 2:49 that morning, a Sunday in April. By early afternoon I had confirmed it would hand the entire database to any logged-in user who asked. I found the bug by using it.</p><h2>The damage, in plain numbers</h2><p>The app was in pre-launch beta. The database held sixteen accounts, all mine or belonging to testers I know personally. That count comes from the database itself and is technically a floor, since deleted accounts purge out of it, but I know of none deleted from that era. The endpoint went live at 2:49 am and the fix was committed at 1:08 pm the same day, a window of just over ten hours. The repo&#8217;s own record says the vulnerable version briefly reached production; it was caught before any account I do not personally know was in a position to hit it. No stranger&#8217;s data was exposed, because there were no strangers yet.</p><p>The stakes were low. The bug class is what this post is about.</p><h2>The mechanism</h2><p>The app authenticates with a JWT whose payload carries the user&#8217;s id under the key id. The export handler read req.user._id. That property does not exist on the decoded payload, so its value was undefined.</p><p>One layer down, Mongoose has a default behavior that turns this mistake into a leak: it strips undefined values out of query filters. A query for user undefined does not throw, and it does not match nothing. The filter is silently deleted, and the query matches everything. The export gathered incomes, expenses, budgets, debts, and recurring items with the user scope gone, and packaged all of it into the download.</p><p>Two defaults collided here: an auth payload that spells it id, and a data layer that treats undefined as no opinion rather than no match. Either one alone is harmless. Together they turned a one-property mistake into a full-collection read. <a href="https://gist.github.com/erikh-14/55639f0999cfbd3d9abc3f25f7015acb">First gist</a>.</p><h2>Why that line gets written</h2><p>The obvious question is how the line got written at all, and the answer is that it is the most likely line. req.user._id is the idiomatic shape in nearly every Express and Mongoose codebase in existence. Mongoose documents carry _id. Tutorials attach the whole user document to the request. If you have absorbed a thousand Express apps, and both tired humans and language models have, then _id is the highest-probability completion. Knowing it is wrong here requires holding one fact about this specific codebase, that the JWT signs id, in your head at the exact moment you read the query.</p><p>This is the part I want you to keep: the characteristic failure of AI-assisted code is not random error. It is plausible error. The line compiles, lints clean, and looks exactly like production code, because statistically speaking it is production code, just for a different app.</p><p>A single-user development database cannot catch this bug, and single-user is what most development databases are. The scoped query and the unscoped query return identical rows until a second account exists, so anyone testing against one user&#8217;s seed data watches the feature work, in the only sense anyone checked. The only reason the leak was visible in my download at all is that the database already held more than one account&#8217;s data. One step earlier in the app&#8217;s life, the export would have looked correct.</p><h2>The fix that does not matter, and the four that do</h2><p>The one-line fix took a minute: read the id the middleware actually provides. If that had been the whole response, I would have been betting the app on nobody ever making the most statistically likely mistake again. Over enough commits someone makes it, whoever or whatever is writing the code.</p><p>So within a day I closed off the class instead.</p><p>I started with the middleware, in the fix commit itself. It now rejects any token whose payload lacks the id, and it publishes the id on one canonical request property. A handler can no longer run with an undefined user id, because the request dies at the door with a 401.</p><p>In the same commit I put a guard in the query layer, and extended it that night into what it is now. A global Mongoose plugin applies to every model that has a user field: any find, update, delete, or count whose filter is missing the user, or carries it as undefined or null, throws instead of running. Aggregation pipelines must start by matching on the user or they refuse too. The guard teaches the bug in its own error text. It refuses to run, reports that the user filter is undefined or null and that this is likely an auth bug, and then, verbatim: &#8220;Read req.userId, not req.user._id.&#8221; The next person to trip it gets the diagnosis and the fix in the same message. Cross-user maintenance scripts opt out with an explicit option, so every unscoped query in the codebase is a visible, deliberate decision. If you know row-level security from Postgres, this is that idea rebuilt at the data layer, minus the database enforcing it for you. <a href="https://gist.github.com/erikh-14/7ce1c759399ce246999d5002a27e1e7c">Second gist</a>.</p><p>Forty-six minutes after the fix, I added a manifest. A test file declares every protected route in the app and reconciles the list against the live Express router in both directions. Mount a route without adding it to the manifest and the build fails. Remove a route and leave a stale entry and the build fails. For every manifested route that can be invoked safely, the suite creates two real users, fills the second with sentinel data, calls the route as the first, and asserts that none of the second user&#8217;s data appears in the response. <a href="https://gist.github.com/erikh-14/f1bd08fbc4360b534f4ed415be395c98">Third gist</a>.</p><p>Just after 1 am I shipped one more commit carrying two things: the guard extension described above, and the last gap, a test that bans raw driver access in production code. The guard only sees queries that go through Mongoose, so code reaching for the raw MongoDB collection would bypass it entirely. CI now fails on those patterns unless a specific line is explicitly allowlisted.</p><p>One more thing, because a test you have never seen fail is a rumor. While writing this post, I re-broke the incomes controller on purpose, restoring the exact original bug, and ran the multi-tenancy suite. One test went red, precisely the route I had broken, failing with the 500 the query guard forces. I reverted it and the suite went back to 36 of 36 green. The suite catches the thing it claims to catch, and I have the failing run to show for it. <a href="https://gist.github.com/erikh-14/b5b3bac08c186318094af787c59d18a6">Fourth gist</a>.</p><h2>Vigilance does not scale. Gates do.</h2><p>Code review would not have reliably caught the original line, because the original line looks correct. Catching it by eye requires a reviewer who knows the JWT payload shape and happens to be thinking about it at the right moment, and nobody reviews at that resolution on an ordinary Tuesday.</p><p>The gates do not get tired. Every new route now forces a manifest decision at build time. Every user-scoped query is checked on every test run. The most probable wrong line is still the most probable wrong line, and something will type it into this codebase again eventually. The difference is that it can no longer reach production quietly.</p><p>The cost is real but small: one manifest entry per new route, one test suite to maintain. What it bought is that an entire class of data leak switched from silent to loud. If code lands in your repo faster than you can hold every convention in your head, and with an agent in the loop it does, the failures have to be loud.</p><p>Receipts: the diff, the query guard, the manifest skeleton, and the failing run are in the gists linked above. Every date and number in this post comes from the git history and the recorded test runs.</p><p>I build <a href="https://budgetsimpler.com">BudgetSimpler</a>, a budgeting app, solo. These posts document how, with receipts from the repo.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://erikhernandez.dev/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://erikhernandez.dev/subscribe?"><span>Subscribe now</span></a></p><div><hr></div><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://erikhernandez.dev/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading With Receipts! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item></channel></rss>