The 403 that wasn't a 403
All posts

The 403 that wasn't a 403

The GitHub contribution graph on my portfolio said “Activity data unavailable.” The token was set. The API was up. Everything looked fine. It wasn’t.

This is the story of a bug that hid behind a perfectly valid HTTP status code and a completely empty response body. It took four wrong theories before I found the right one.

The setup

My Astro portfolio fetches GitHub contribution data during the static build and bakes it into the HTML. No client-side JavaScript, no runtime API calls, just a single fetch to GitHub’s GraphQL endpoint during astro build. The environment variable was set. The token had every scope it needed. I could curl the exact same endpoint from the same shell and get perfect results.

But the build log said GitHub API returned 403. Every time.

Four dead ends

The first theory was the obvious one: maybe import.meta.env wasn’t picking up the token. Astro uses Vite under the hood, and Vite’s environment variable resolution has its own opinions about what gets exposed and when. I added a debug log. The token was there. Length was right, prefix was right. Dead end.

The second theory was token permissions. Maybe the scopes were wrong or the token had expired. I checked the x-oauth-scopes header against the GitHub docs. Every scope was there, including the ones required for the GraphQL API. I tested the token against the REST endpoint. Worked perfectly. Dead end.

The third theory was that Vite was mangling the token during the build. Maybe string escaping, maybe template literal interpolation, maybe some SSR bundling quirk. I logged the exact token value from inside the function during the prerender phase. Character-for-character identical to what was in the environment. Dead end.

The fourth theory was a transient issue. Maybe GitHub rate-limited the build, or there was a temporary block. I rebuilt. Same 403. Rebuilt again. Same 403. Not transient. Dead end.

The response body that changed everything

On theory number five, I stopped looking at the request and started looking at the response. I logged the response body along with the status code, something I should have done from the start.

The body wasn’t JSON. It was HTML. And it said: “Request forbidden by administrative rules. Please make sure your request has a User-Agent header.”

That was it. Vite’s prerender environment sends fetch requests without a User-Agent header. GitHub requires one. Not for authentication, not for rate limiting, but as a basic administrative rule. Without it, they return a bare 403 with an HTML error page and no JSON payload, which is why the error message looked like a token problem instead of what it actually was: a missing header problem.

The fix was one line. I added "User-Agent": "portfolio-build" to the request headers. The build succeeded immediately.

What I should have done differently

The moment I got a non-200 response, I should have logged the response body. Not the status code, not my assumptions about what went wrong, but the actual response. GitHub told me exactly what was wrong. I just wasn’t listening.

When the error response is empty, it’s almost never about your credentials. It’s about your request shape. Missing headers, wrong content type, malformed body. The API didn’t even get far enough to check the token because the request violated a rule that runs before authentication.

A 403 doesn’t always mean “unauthorized.” Sometimes it means “we couldn’t even start processing this request because you forgot something fundamental.” The difference between those two scenarios lives in the response body, and that’s the first place you should look.


This article was edited by AI for structure (adding headings).