403 vs 404: Why GitHub Hides Private Repos

Steal Like an Engineer #1

One letter.

Cacti, an open-source network monitoring tool, answered a failed login with “Login failed” when the username existed and “Login Failed” when it didn’t. That capital F told anyone guessing which accounts were real. It became a public vulnerability, CVE-2026-49442, on October 23, 2026.

GitHub closed this hole long ago. I just didn’t notice.

I have private repos on GitHub. Log out, open one, and GitHub says it doesn’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’t broken. It’s lying to me on purpose.

403 vs 404: what each answer tells the caller

There are two ways for a server to say no.

403 Forbidden means “it’s here, but not for you.” 404 Not Found means “there’s nothing here.”

A 403 is a locked door, and a locked door proves there’s a room. A 404 is a wall.

403 Forbidden404 Not Found
What the caller learnsIt exists, but you can’t have itNothing is here
Confirms the thing exists?YesNo
Use it whenThe caller can fix it, for example by logging inEven knowing it exists is a secret

Two cards. 403 Forbidden: a stranger learns something real is here. 404 Not Found: a stranger learns nothing at all.

Why GitHub returns 404 for private repos

GitHub chose the wall, and it says why in one sentence on a troubleshooting page almost nobody reads. It answers 404 “instead of a 403 Forbidden response to avoid confirming the existence of private repositories.”

This isn’t a hack. The HTTP standard allows it. RFC 9110 says a server that wants to “hide” a forbidden resource “MAY instead respond with a status code of 404.”

And there’s a lot behind that wall. In GitHub’s Octoverse 2025 report, 81.5% of all contributions happened in private repos. Most of GitHub is wall.

Try it: open a private repo while logged out

Here’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.

The website does the same. Both pages show an identical “Page not found”. From the outside, my private repo and a typo are the same thing.

Should you return 403 or 404?

Not always 404. The wall has a cost, and the owner pays it too.

Logged out, I get the same “doesn’t exist” for my own repo. So does a teammate who forgot to sign in. GitHub’s own troubleshooting page has to remind people that if they know it exists, “you should check your authentication.”

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’s invoice, an email that may or may not have an account.

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’s real. Make them against GitHub, and you walk away with 1,000 identical answers. It’s the attack the wall stops.

What a 404 alone does not hide

The status code is one part of the answer. Everything around it has to match too.

The body. Cacti’s leak wasn’t a status code at all. It was this:

- Login failed    <- the user exists
+ Login Failed    <- the user does not exist

Other pages. One page can follow the rule while another forgets it. Every page that touches the private thing has to give the same answer.

The clock. If “private” takes longer to answer than “missing”, a stopwatch gives the secret away, whatever the status code says.

How we use it: GitHub’s wall, around our posters

Our posters go to business owners, so two things on our side are private: the artwork, and the list of who it’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.

It’s the same wall GitHub puts in front of a private repo, rebuilt around our posters.

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.

How to return 404 instead of 403

OWASP, the Open Worldwide Application Security Project, gives the same advice: “map both cases to the same public response, such as a 404.” In plain words, your app needs one rule:

When someone asks for a file:
  it doesn't exist   -> "404 Not Found"
  it isn't theirs    -> "404 Not Found"   (the same answer)
  it's theirs        -> here it is

Every web framework can do this in a few lines. The trick is to look up only what belongs to the person asking, so “not yours” and “doesn’t exist” become the same answer automatically.

Then check it. Sign in as a normal user, ask for something that belongs to someone else, then ask for something that doesn’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 “Forbidden” and the other “Not Found”. Same size, different meaning. Compare the words, not the size.

If you do one thing after reading this, run that check on your own app.

Where else this applies

Anywhere a “no” could confirm that something exists:

  • Password reset. Say “If that email has an account, we’ve sent a link,” whether it has one or not.
  • Invite and share links. A wrong or expired link should look exactly like one that never existed.
  • Admin pages. An /admin that answers 403 tells a scanner where to dig.

The wall can point at bots too. Answer 404 to a crawler and, as far as it knows, your site isn’t there.

It only stops the polite ones. Polite bots say who they are, and they already obey robots.txt, 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.

The question I should have asked years ago

The answer was in GitHub’s docs the whole time. I only found it the day I asked the exact question: why 404, and not 403?

AI works the same way. It hands you the basics until you ask for the specific thing. Learning to ask is the skill.

An error message is information. Give strangers none.

Sources

  1. GitHub Docs, Troubleshooting the REST API: 404 Not Found for an existing resource
  2. IETF, RFC 9110 HTTP Semantics, section 15.5.4 (403 Forbidden)
  3. GitHub Blog, Octoverse 2025
  4. NVD, CVE-2026-49442 (Cacti)
  5. OWASP, Insecure Direct Object Reference Prevention Cheat Sheet

Steal Like an Engineer #1. Stolen from: GitHub. More from me: linkedin.com/in/ukantjadia.

Ukant Jadia
Ukant Jadia
Backend & AI Engineer ยท Team Lead, Caprae Capital

AI can ship. I make it earn. Backend & AI engineer and team lead at Caprae Capital. I turn vibe code into production code.