Skip to content

Require an API key on /api/* - #13

Open
ashwinimanoj wants to merge 2 commits into
mainfrom
feat/api-key-auth
Open

Require an API key on /api/*#13
ashwinimanoj wants to merge 2 commits into
mainfrom
feat/api-key-auth

Conversation

@ashwinimanoj

@ashwinimanoj ashwinimanoj commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Every route under /api/* now requires Authorization: Bearer <key>, matched against a new API_KEY environment variable. /health and the HTML pages stay open.

What changed

File
src/auth.js New. The requireApiKey middleware
src/app.js One line — mounts it in front of apiRoutes
test/auth.test.js 15 cases, driven through the real app
test/helpers/supabase-stub.js Stands in for PostgREST so tests need no network
package.json npm testnode --test
README.md, .env.example Documentation

Behaviour

Rejected requests get 401 and {"error":"Unauthorized"} — nothing more. The reason goes on the span as auth.rejected_reason:

Value Meaning
missing No Authorization header
malformed Not Bearer <token>, or an empty token
mismatch Well-formed but wrong — including when no key is configured

That separates a caller that forgot the header from one guessing keys, without telling the caller which it was.

Fails closed. With API_KEY unset, nothing can match it, so a deployment that never received the variable rejects everything rather than serving the dataset to the internet.

Rejections reuse the existing error handler, so they log at warn rather than error and the fault rate keeps measuring faults.

What this does not protect

The HTML pages read the same data the API serves and stay open, because a browser has nowhere to put a bearer token. This is an access gate on automation, not a security boundary around the dataset. Stated plainly in the README rather than left to be discovered.

Known consequence for tracing

A rejected request never reaches Express's router, so req.route is unset and nameSpanAfterRoute cannot rename the span. 401s keep the bare GET/POST name with no http.route. Filter on auth.rejected_reason instead of by route.

Tests

node:test, no new dependencies — engines already requires Node 20+.

Tests drive the real createApp() over HTTP rather than mounting the middleware on a throwaway app, which is what proves it is wired in front of the right routes. Verified by mutation: unwiring it from app.js fails 6 tests, weakening the key comparison fails 3.

ℹ tests 15
ℹ pass 15
ℹ fail 0

Rollout — order matters

The loadgen in office-k8s-workloads already sends Authorization: Bearer, and today's service ignores it, so the caller can be armed before the gate exists:

  1. Generate a key: openssl rand -hex 32
  2. kubectl create secret generic inventory-service -n observability --from-literal=token=<key>
  3. Merge infraspecdev/office-k8s-workloads#44 — loadgen starts sending the header
  4. Set API_KEY in the Vercel project
  5. Merge this PR

Steps 4–5 before 2–3 gives a CrashLoopBackOff: the loadgen's startup call is not retried and exit(1)s on a non-OK response.

Every route under /api/* now needs `Authorization: Bearer <key>`, matched
against API_KEY. The HTML pages and /health stay open — the dashboard is
the demo, and a browser has nowhere to put a bearer token.

Fails closed. With API_KEY unset nothing can match it, so a deployment
that never received the variable rejects every request rather than
serving the dataset to the internet.

The response says only that the request was rejected. Why it was rejected
goes on the span as `auth.rejected_reason` — missing, malformed or
mismatch — which separates a caller that forgot the header from one
guessing keys, without handing that distinction to the caller. Rejections
reuse the existing error handler, so they log at warn rather than error
and leave the fault rate measuring faults.

A rejected request never reaches the router, so nameSpanAfterRoute cannot
rename its span: 401s keep the bare `GET`/`POST` name with no http.route.
Filtering on auth.rejected_reason finds them instead.

Tests run on node:test, added as `npm test`. They drive the real app over
HTTP against a Supabase stand-in, which is what proves the gate is mounted
in front of the right routes and not merely that the middleware works.
Records the header callers send, the fail-closed behaviour, and the
auth.rejected_reason values, so the reason a request was rejected can be
found where it actually lives rather than in the response body.

States the limit plainly: the HTML pages read the same data the API
serves, so the key protects the interface, not the dataset.
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vercel-demo Ready Ready Preview Aug 19, 2026 6:14pm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant