koala73/worldmonitor · error
unauthorized
Error message
unauthorized
What it means
The consumer-prices-core Fastify server registers a global onRequest hook (consumer-prices-core/src/api/server.ts:46) that compares the request's 'x-api-key' header against the server key using a length + timingSafeEqual check (matchesApiKey, server.ts:26). The server key comes from options.apiKey or the WORLDMONITOR_SNAPSHOT_API_KEY env var, trimmed at startup. Any request other than GET /health whose header is missing or not byte-identical to that key gets HTTP 401 with body { error: 'unauthorized' }.
Source
Thrown at consumer-prices-core/src/api/server.ts:51
const candidates = Array.isArray(provided) ? provided : [provided];
return candidates.some((candidate) => matchesApiKey(candidate, apiKey));
}
export function createServer(options: ConsumerPricesServerOptions = {}) {
const apiKey = Buffer.from(requiredApiKey(options.apiKey));
const server = Fastify({ logger: options.logger ?? { level: process.env.LOG_LEVEL ?? 'info' } });
server.register(cors, {
origin: options.corsOrigin ?? process.env.CORS_ORIGIN ?? '*',
methods: ['GET'],
});
server.addHook('onRequest', async (request, reply) => {
if (isHealthCheckPath(request.url)) return;
const provided = request.headers['x-api-key'];
if (!isAuthorizedApiKey(provided, apiKey)) {
return reply.status(401).send({ error: 'unauthorized' });
}
});
server.register(worldmonitorRoutes, { prefix: '/wm/consumer-prices/v1' });
server.register(healthRoutes, { prefix: '/health' });
return server;
}
function isInvokedAsScript(entryPath: string | undefined, moduleUrl: string): boolean {
if (!entryPath) return false;
try {
const entry = pathToFileURL(realpathSync(entryPath)).href;
const self = pathToFileURL(realpathSync(fileURLToPath(moduleUrl))).href;
return entry === self;
} catch {
return moduleUrl === pathToFileURL(entryPath).href;
}View on GitHub (pinned to eeab0a219f)
Solutions
- Send the header on every data request: 'x-api-key: <WORLDMONITOR_SNAPSHOT_API_KEY>'. Only /health is exempt (isHealthCheckPath).
- Verify both processes resolve the same value: the server reads options.apiKey ?? process.env.WORLDMONITOR_SNAPSHOT_API_KEY; confirm the caller's env var name and value match (compare a hash of the key, never log the secret).
- Trim the key when reading it client-side before setting the header — the comparison is length-exact plus timingSafeEqual, so trailing whitespace fails.
- Confirm reachability with GET /health first (no auth), then retry the /wm/consumer-prices/v1 route with the header.
- In tests, pass the key explicitly via createServer({ apiKey: 'test-key' }) and send that exact value.
Example fix
// before
const res = await fetch('http://localhost:3400/wm/consumer-prices/v1/snapshot');
// 401 { error: 'unauthorized' }
// after
const apiKey = process.env.WORLDMONITOR_SNAPSHOT_API_KEY!.trim();
const res = await fetch('http://localhost:3400/wm/consumer-prices/v1/snapshot', {
headers: { 'x-api-key': apiKey },
});
if (res.status === 401) throw new Error('consumer-prices key rejected: check WORLDMONITOR_SNAPSHOT_API_KEY on client and server'); Defensive patterns
Strategy: validation
Validate before calling
// Fail fast before the request when the key is not configured on the caller side.
const apiKey = process.env.WORLDMONITOR_SNAPSHOT_API_KEY?.trim();
if (!apiKey) {
throw new Error('WORLDMONITOR_SNAPSHOT_API_KEY is not set; the request would return 401');
}
const res = await fetch(url, { headers: { 'x-api-key': apiKey } }); Try / catch
// fetch does NOT throw on 401 — branch on the status; treat it as a config error, not a transient one.
const res = await fetch(url, { headers: { 'x-api-key': apiKey } });
if (res.status === 401) {
throw new Error('consumer-prices rejected the API key: check WORLDMONITOR_SNAPSHOT_API_KEY on client and server');
}
if (!res.ok) throw new Error(`consumer-prices error ${res.status}`);
return res.json(); Prevention
- Store the key once in a shared secret source so server and callers rotate together.
- Trim the secret when reading it from env or a secrets manager before setting the header.
- Never retry-loop a 401 — it indicates mismatched credentials, not contention.
- Smoke-test deployments with GET /health (exempt) before debugging authorized routes.
When it happens
Trigger: Calling GET /wm/consumer-prices/v1/* without an x-api-key header; sending a key that differs from WORLDMONITOR_SNAPSHOT_API_KEY (stale or rotated value on the caller side); sending a key with a trailing newline or space (the header candidate is compared byte-exact, only the server-side env value is trimmed); using 'Authorization: Bearer ...' instead of 'x-api-key'; server constructed with createServer({ apiKey }) so the env value the client knows is not the one in effect.
Common situations: Local dev where the calling service does not load the .env holding WORLDMONITOR_SNAPSHOT_API_KEY (dotenv/config only runs inside the server process); key rotated in one deployment but not the other; curl or CI smoke tests forgetting the header; secrets-manager values copied with a trailing newline; testing against a preview deployment provisioned with a different key.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- API key required
- API key required
- failed to build coverage snapshot
- failed to build overview snapshot
- failed to build movers snapshot
AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21).
Data as JSON: /api/errors/3644b13f9f9cb77a.
Report an issue: GitHub.