<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Steal Like an Engineer | Ukant Jadia</title><link>https://ukantjadia.github.io/tag/steal-like-an-engineer/</link><atom:link href="https://ukantjadia.github.io/tag/steal-like-an-engineer/index.xml" rel="self" type="application/rss+xml"/><description>Steal Like an Engineer</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Tue, 27 Oct 2026 04:47:00 +0530</lastBuildDate><image><url>https://ukantjadia.github.io/media/icon_hu5e29e1df12b63ca0c9252c8db4842f1b_8915_512x512_fill_lanczos_center_3.png</url><title>Steal Like an Engineer</title><link>https://ukantjadia.github.io/tag/steal-like-an-engineer/</link></image><item><title>403 vs 404: Why GitHub Hides Private Repos</title><link>https://ukantjadia.github.io/post/403-vs-404-github-private-repos/</link><pubDate>Tue, 27 Oct 2026 04:47:00 +0530</pubDate><guid>https://ukantjadia.github.io/post/403-vs-404-github-private-repos/</guid><description>&lt;p>One letter.&lt;/p>
&lt;p>Cacti, an open-source network monitoring tool, answered a failed login with &amp;ldquo;Login failed&amp;rdquo; when the username existed and &amp;ldquo;Login Failed&amp;rdquo; when it didn&amp;rsquo;t. That capital F told anyone guessing which accounts were real. It became a public vulnerability, CVE-2026-49442, on October 23, 2026.&lt;/p>
&lt;p>GitHub closed this hole long ago. I just didn&amp;rsquo;t notice.&lt;/p>
&lt;p>I have private repos on GitHub. Log out, open one, and GitHub says it doesn&amp;rsquo;t exist. For years I read that as a glitch. A few months ago I finally asked why, and the answer was simple: it isn&amp;rsquo;t broken. It&amp;rsquo;s lying to me on purpose.&lt;/p>
&lt;h2 id="403-vs-404-what-each-answer-tells-the-caller">403 vs 404: what each answer tells the caller&lt;/h2>
&lt;p>There are two ways for a server to say no.&lt;/p>
&lt;p>403 Forbidden means &amp;ldquo;it&amp;rsquo;s here, but not for you.&amp;rdquo; 404 Not Found means &amp;ldquo;there&amp;rsquo;s nothing here.&amp;rdquo;&lt;/p>
&lt;p>A 403 is a locked door, and a locked door proves there&amp;rsquo;s a room. A 404 is a wall.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>403 Forbidden&lt;/th>
&lt;th>404 Not Found&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>What the caller learns&lt;/td>
&lt;td>It exists, but you can&amp;rsquo;t have it&lt;/td>
&lt;td>Nothing is here&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Confirms the thing exists?&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Use it when&lt;/td>
&lt;td>The caller can fix it, for example by logging in&lt;/td>
&lt;td>Even knowing it exists is a secret&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>
&lt;figure >
&lt;div class="d-flex justify-content-center">
&lt;div class="w-100" >&lt;img alt="Two cards. 403 Forbidden: a stranger learns something real is here. 404 Not Found: a stranger learns nothing at all." srcset="
/post/403-vs-404-github-private-repos/01b-two-answers_huf1f01eeb36e659960df90d5abc331a32_65022_8898d1f95f0467d0761237e03d279034.webp 400w,
/post/403-vs-404-github-private-repos/01b-two-answers_huf1f01eeb36e659960df90d5abc331a32_65022_cefea37d08588e7b95ae65b410048411.webp 760w,
/post/403-vs-404-github-private-repos/01b-two-answers_huf1f01eeb36e659960df90d5abc331a32_65022_1200x1200_fit_q75_h2_lanczos_2.webp 1200w"
src="https://ukantjadia.github.io/post/403-vs-404-github-private-repos/01b-two-answers_huf1f01eeb36e659960df90d5abc331a32_65022_8898d1f95f0467d0761237e03d279034.webp"
width="608"
height="760"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;h2 id="why-github-returns-404-for-private-repos">Why GitHub returns 404 for private repos&lt;/h2>
&lt;p>GitHub chose the wall, and it says why in one sentence on a troubleshooting page almost nobody reads. It answers 404 &amp;ldquo;instead of a 403 Forbidden response to avoid confirming the existence of private repositories.&amp;rdquo;&lt;/p>
&lt;p>This isn&amp;rsquo;t a hack. The HTTP standard allows it. RFC 9110 says a server that wants to &amp;ldquo;hide&amp;rdquo; a forbidden resource &amp;ldquo;MAY instead respond with a status code of 404.&amp;rdquo;&lt;/p>
&lt;p>And there&amp;rsquo;s a lot behind that wall. In GitHub&amp;rsquo;s Octoverse 2025 report, 81.5% of all contributions happened in private repos. Most of GitHub is wall.&lt;/p>
&lt;h2 id="try-it-open-a-private-repo-while-logged-out">Try it: open a private repo while logged out&lt;/h2>
&lt;p>Here&amp;rsquo;s how solid the wall is. Logged out, I asked GitHub for one of my private repos and for a name nobody owns. Both came back exactly the same, byte for byte: 404 Not Found, 132 bytes long.&lt;/p>
&lt;p>The website does the same. Both pages show an identical &amp;ldquo;Page not found&amp;rdquo;. From the outside, my private repo and a typo are the same thing.&lt;/p>
&lt;video autoplay muted loop playsinline controls >
&lt;source src="https://ukantjadia.github.io/post/403-vs-404-github-private-repos/01e-terminals.mp4" type="video/mp4">
&lt;/video>
&lt;h2 id="should-you-return-403-or-404">Should you return 403 or 404?&lt;/h2>
&lt;p>Not always 404. The wall has a cost, and the owner pays it too.&lt;/p>
&lt;p>Logged out, I get the same &amp;ldquo;doesn&amp;rsquo;t exist&amp;rdquo; for my own repo. So does a teammate who forgot to sign in. GitHub&amp;rsquo;s own troubleshooting page has to remind people that if they know it exists, &amp;ldquo;you should check your authentication.&amp;rdquo;&lt;/p>
&lt;p>So choose on purpose. Return 403 when the caller can fix the problem, usually by logging in, and telling them helps. Return 404 when even knowing the thing exists is the secret: a private repo, someone else&amp;rsquo;s invoice, an email that may or may not have an account.&lt;/p>
&lt;p>That last one has a name: enumeration. Guess names, watch the answers, keep the ones that come back different. Make 1,000 guesses against a server that says 403, and you walk away with a list of every name that&amp;rsquo;s real. Make them against GitHub, and you walk away with 1,000 identical answers. It&amp;rsquo;s the attack the wall stops.&lt;/p>
&lt;h2 id="what-a-404-alone-does-not-hide">What a 404 alone does not hide&lt;/h2>
&lt;p>The status code is one part of the answer. Everything around it has to match too.&lt;/p>
&lt;p>&lt;strong>The body.&lt;/strong> Cacti&amp;rsquo;s leak wasn&amp;rsquo;t a status code at all. It was this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-diff" data-lang="diff">&lt;span class="line">&lt;span class="cl">&lt;span class="gd">- Login failed &amp;lt;- the user exists
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="gd">&lt;/span>&lt;span class="gi">+ Login Failed &amp;lt;- the user does not exist
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Other pages.&lt;/strong> One page can follow the rule while another forgets it. Every page that touches the private thing has to give the same answer.&lt;/p>
&lt;p>&lt;strong>The clock.&lt;/strong> If &amp;ldquo;private&amp;rdquo; takes longer to answer than &amp;ldquo;missing&amp;rdquo;, a stopwatch gives the secret away, whatever the status code says.&lt;/p>
&lt;h2 id="how-we-use-it-githubs-wall-around-our-posters">How we use it: GitHub&amp;rsquo;s wall, around our posters&lt;/h2>
&lt;p>Our posters go to business owners, so two things on our side are private: the artwork, and the list of who it&amp;rsquo;s for. We built a private link that knows the difference. It hands over the artwork. It never hands over the list. And everything it refuses gets the same 404.&lt;/p>
&lt;p>It&amp;rsquo;s the same wall GitHub puts in front of a private repo, rebuilt around our posters.&lt;/p>
&lt;video autoplay muted loop playsinline controls >
&lt;source src="https://ukantjadia.github.io/post/403-vs-404-github-private-repos/01d-door.mp4" type="video/mp4">
&lt;/video>
&lt;p>&lt;em>Four requests reach the private link. Only the artwork gets through. The recipient list, a wrong link and anything else all hit the same 404.&lt;/em>&lt;/p>
&lt;h2 id="how-to-return-404-instead-of-403">How to return 404 instead of 403&lt;/h2>
&lt;p>OWASP, the Open Worldwide Application Security Project, gives the same advice: &amp;ldquo;map both cases to the same public response, such as a 404.&amp;rdquo; In plain words, your app needs one rule:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">When someone asks for a file:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> it doesn&amp;#39;t exist -&amp;gt; &amp;#34;404 Not Found&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> it isn&amp;#39;t theirs -&amp;gt; &amp;#34;404 Not Found&amp;#34; (the same answer)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> it&amp;#39;s theirs -&amp;gt; here it is
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Every web framework can do this in a few lines. The trick is to look up only what belongs to the person asking, so &amp;ldquo;not yours&amp;rdquo; and &amp;ldquo;doesn&amp;rsquo;t exist&amp;rdquo; become the same answer automatically.&lt;/p>
&lt;p>Then check it. Sign in as a normal user, ask for something that belongs to someone else, then ask for something that doesn&amp;rsquo;t exist. If the two answers match word for word, nothing leaks. When I tested a deliberately broken version, both answers were 22 bytes long, but one said &amp;ldquo;Forbidden&amp;rdquo; and the other &amp;ldquo;Not Found&amp;rdquo;. Same size, different meaning. Compare the words, not the size.&lt;/p>
&lt;p>If you do one thing after reading this, run that check on your own app.&lt;/p>
&lt;h2 id="where-else-this-applies">Where else this applies&lt;/h2>
&lt;p>Anywhere a &amp;ldquo;no&amp;rdquo; could confirm that something exists:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Password reset.&lt;/strong> Say &amp;ldquo;If that email has an account, we&amp;rsquo;ve sent a link,&amp;rdquo; whether it has one or not.&lt;/li>
&lt;li>&lt;strong>Invite and share links.&lt;/strong> A wrong or expired link should look exactly like one that never existed.&lt;/li>
&lt;li>&lt;strong>Admin pages.&lt;/strong> An &lt;code>/admin&lt;/code> that answers 403 tells a scanner where to dig.&lt;/li>
&lt;/ul>
&lt;p>The wall can point at bots too. Answer 404 to a crawler and, as far as it knows, your site isn&amp;rsquo;t there.&lt;/p>
&lt;p>It only stops the polite ones. Polite bots say who they are, and they already obey &lt;code>robots.txt&lt;/code>, the file where a site lists what bots may read. The scrapers you want gone pretend to be an ordinary browser. And point the wall at Googlebot by mistake and you drop out of Google. Turning bots away from your own site is your call. Aim carefully.&lt;/p>
&lt;h2 id="the-question-i-should-have-asked-years-ago">The question I should have asked years ago&lt;/h2>
&lt;p>The answer was in GitHub&amp;rsquo;s docs the whole time. I only found it the day I asked the exact question: why 404, and not 403?&lt;/p>
&lt;p>AI works the same way. It hands you the basics until you ask for the specific thing. Learning to ask is the skill.&lt;/p>
&lt;p>An error message is information. Give strangers none.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ol>
&lt;li>GitHub Docs, &lt;a href="https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api#404-not-found-for-an-existing-resource" target="_blank" rel="noopener">Troubleshooting the REST API: 404 Not Found for an existing resource&lt;/a>&lt;/li>
&lt;li>IETF, &lt;a href="https://www.rfc-editor.org/rfc/rfc9110#section-15.5.4" target="_blank" rel="noopener">RFC 9110 HTTP Semantics, section 15.5.4 (403 Forbidden)&lt;/a>&lt;/li>
&lt;li>GitHub Blog, &lt;a href="https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/" target="_blank" rel="noopener">Octoverse 2025&lt;/a>&lt;/li>
&lt;li>NVD, &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-49442" target="_blank" rel="noopener">CVE-2026-49442 (Cacti)&lt;/a>&lt;/li>
&lt;li>OWASP, &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html" target="_blank" rel="noopener">Insecure Direct Object Reference Prevention Cheat Sheet&lt;/a>&lt;/li>
&lt;/ol>
&lt;hr>
&lt;p>&lt;em>Steal Like an Engineer #1. Stolen from: GitHub.&lt;/em> More from me: &lt;a href="https://www.linkedin.com/in/ukantjadia/" target="_blank" rel="noopener">linkedin.com/in/ukantjadia&lt;/a>.&lt;/p></description></item></channel></rss>