Authorization: bearer undefined
No error. No stack trace. Just a 401 that looked like it came from nowhere.
I was poking around CommitPulse locally — it's the open-source project that generates those GitHub streak/contribution SVGs you see embedded in people's READMEs. I hadn't set a token in my .env yet. Figured I'd see what broke.
What broke was confusing in a specific way: it didn't fail at startup, it didn't fail with a config error, it just quietly sent a bad request to GitHub's GraphQL API and let GitHub be the one to reject it.
Tracing it back
I went looking for where the GitHub client builds its request headers. Found it in lib/github.ts:
// lib/github.ts:185-187 — before
const getHeaders = () => ({
Authorization: `bearer ${process.env.GITHUB_PAT || process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
});
That's it. That's the whole bug.
If neither GITHUB_PAT nor GITHUB_TOKEN is set, process.env.GITHUB_PAT || process.env.GITHUB_TOKEN evaluates to undefined. Template literals don't care. They'll happily stringify that into the header anyway:
Authorization: bearer undefined
That's a syntactically valid HTTP header. GitHub's API has no way to know "undefined" isn't a real token — it just sees garbage credentials and responds with a 401, same as it would for any other malformed token.
Why this one's easy to miss
The failure mode looks exactly like an upstream GitHub problem. You get a 401, you check your token, you check your scopes, maybe you regenerate the PAT — none of that helps, because the actual bug is three layers upstream of the request that's failing.
It also doesn't fail in CI or in prod, where the env var is almost always set. It only shows up for a contributor cloning the repo fresh and trying to run it locally without realizing a token is required. So the people most likely to hit it are exactly the people least equipped to debug it — first-time contributors, during setup, before they have any context on the codebase.
The fix
Resolve the token once, validate it's actually there, and fail loudly before the request goes out:
// lib/github.ts — after
const MISSING_GITHUB_TOKEN_MESSAGE = 'GitHub token is missing. Set GITHUB_PAT or GITHUB_TOKEN.';
function getGitHubToken(): string {
const token = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN;
if (!token || token.trim() === '') {
throw new Error(MISSING_GITHUB_TOKEN_MESSAGE);
}
return token;
}
const getHeaders = () => ({
Authorization: `bearer ${getGitHubToken()}`,
'Content-Type': 'application/json',
});
Same fallback behavior — GITHUB_PAT first, GITHUB_TOKEN second — but now a missing token throws a clear Error before fetchWithRetry ever gets called. No network round trip. No GitHub 401 to misread. Just an immediate, readable message pointing at the actual problem.
The tests cover all three cases now: GITHUB_PAT present, falling back to GITHUB_TOKEN, and neither set:
it('throws before fetching when no GitHub token is configured', async () => {
delete process.env.GITHUB_PAT;
delete process.env.GITHUB_TOKEN;
await expect(fetchGitHubContributions('octocat')).rejects.toThrow(
'GitHub token is missing. Set GITHUB_PAT or GITHUB_TOKEN.'
);
expect(fetch).not.toHaveBeenCalled();
});
That last assertion matters as much as the error message — it confirms the fetch never even fires once the token's missing, instead of trusting GitHub to reject it correctly.
What I'd flag for next time
Any place a template literal builds an auth header from an env var deserves a second look. ${process.env.X} will never throw — it'll just smile and hand you the string "undefined". If the thing on the other end of that header is permissive enough to accept a malformed credential and reject it later, your error shows up in the wrong place, attributed to the wrong cause.
If you're tracing a new codebase and you find a request that authenticates with an environment variable — check what happens when that variable isn't set. Don't assume it throws. Go read it. You might find your first PR sitting right there.
Top comments (0)