Protect an API with API keys

Protect a machine-to-machine API with a shared secret key. No auth config needed, a three-line middleware does it.

1. The middleware

Compare the Authorization header against a key from the environment. Read it once at startup and throw if it's missing, so a misconfigured deploy fails at boot instead of running unprotected:

import server from "@server/next";

// API_KEY=a-long-random-string in the environment
const API_KEY = process.env.API_KEY;
if (!API_KEY) throw new Error('Set the API_KEY environment variable');

const requireKey = (ctx) => {
  const [type, key] = String(ctx.headers.authorization || '').split(' ');
  if (type !== 'Bearer' || key !== API_KEY) return 401;
};

Returning 401 stops the request right there; returning nothing lets it through, like any middleware.

2. Protect the routes

Chain it in front of the routes that need the key:

export default server()
  .get('/status', () => 'ok')                       // public
  .get('/data', requireKey, () => db.data.list())   // needs the key
  .post('/data', requireKey, (ctx) => db.data.create(ctx.body));

Or .use() it once to guard every route registered after it:

export default server()
  .get('/status', () => 'ok')            // public
  .use(requireKey)
  .get('/data', () => db.data.list())    // needs the key
  .post('/data', (ctx) => db.data.create(ctx.body));

Callers send the key as a bearer token:

curl -H "Authorization: Bearer a-long-random-string" localhost:3000/data

3. Files behind the key

With the default body parsing, uploaded files stream to storage while the request is being read, before your middleware gets a say. For uploads that must never touch storage without a key, take the raw stream instead and write it after the check:

import server, { bucket } from "@server/next";

const uploads = bucket.FS('./uploads');

export default server()
  .post('/upload/:name', { parser: 'stream' }, requireKey, async (ctx) => {
    await uploads.file(ctx.url.params.name).write(ctx.body);
    return 201;
  });

With parser: 'stream', ctx.body is the unread request stream: nothing is processed until requireKey has passed, and the handler pipes it straight to the bucket without buffering.

4. Several clients (optional)

To hand out one key per client, look the key up instead of comparing it, and put the client on ctx.user so routes know who's calling:

const clients = new Map([
  ['key-for-acme', { id: 'acme' }],
  ['key-for-globex', { id: 'globex' }],
]);

const requireKey = (ctx) => {
  const [type, key] = String(ctx.headers.authorization || '').split(' ');
  const client = type === 'Bearer' && clients.get(key);
  if (!client) return 401;
  ctx.user = client;
};

export default server()
  .use(requireKey)
  .get('/data', (ctx) => db.data.list(ctx.user.id));

Swap the Map for a polystore store to manage keys at runtime.

Next steps

  • Rate-limit per client with the key id from ctx.user.
  • For user-facing sessions (browsers, mobile apps), use the auth option instead.