Back to path
IntermediateTradepost · Project 4 of 12 ~6h· 5 milestones

Authenticate machines with scoped API keys

Continues from the last build: The API has a consistent contract: one error envelope, validated inputs, honest docs.

Right now any curl command that knows the base URL can read every merchant's orders, products and customers.

API key authentication designConstant-time secret comparisonMulti-tenant query scopingScope-based authorization (401 vs 403)Secret lifecycle and rotation without downtimeFastify preHandler hooksDrizzle schema migrationsWriting a cross-tenant isolation test

What you'll build

Every route requires a valid API key, resolves to exactly one merchant, and every database query is scoped to that merchant's id, verified by a cross-tenant test that must 404. Keys are stored as sha256 hashes, scoped to read or write actions, timestamped on use, and revocable or rotatable without a redeploy.

See how we teach, before you sign up

You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:

src/lib/auth.tstypescript
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
import type { FastifyRequest, FastifyReply } from 'fastify';

// TODO: generateApiKey() -> { fullKey, prefix, hash }
// TODO: authenticate() preHandler -> reads Bearer header, looks up api_keys by prefix,
//       compares hashes with timingSafeEqual, attaches request.merchantId / apiKeyScopes
// TODO: requireScope(scope) -> returns a preHandler that 403s if the scope is missing

export function generateApiKey() {
  throw new Error('not implemented');
}

Reading this file

  • import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';Everything you need for key generation and constant-time comparison ships with Node, no new dependency.
  • // TODO: generateApiKey() -> { fullKey, prefix, hash }Milestone 1 fills this in.
  • // TODO: authenticate() preHandlerMilestone 2 fills this in, it is the function every protected route registers.
  • export function generateApiKey() { throw new Error('not implemented'); }Throws on purpose so any route that tries to use it before you finish milestone 1 fails loudly, not silently.

This is the module every route file will import from. Fill in generateApiKey first, it has no dependency on Fastify or the database, then authenticate, then requireScope.

That's 1 of 8 explained code blocks in this single project.

The build, milestone by milestone

  1. 1

    Issue keys the safe way

    6 guided steps

    The single biggest way API keys leak is being stored, logged, or emailed in plaintext. If your own database only ever holds a hash, a database dump or a careless log line cannot hand out a working credential. Showing the raw key exactly once at creation forces the client to save it immediately, the same pattern GitHub and Stripe use for personal access tokens.

  2. 2

    Authenticate every request

    6 guided steps

    A preHandler hook is the one place authentication can live without every route author remembering to call it manually. Comparing hashes with a naive === lets an attacker who can measure response timing learn the hash one byte at a time; crypto.timingSafeEqual removes that side channel for the cost of a few extra lines.

  3. 3

    Scope every query by merchant

    6 guided steps

    Authentication answers who is calling. Authorization at the row level answers whether they can see this particular row, and that has to be enforced in the query itself, not filtered out after the fact. A handler that fetches by id alone and checks merchant afterward still ran a query against another tenant's data and is one refactor away from leaking it.

  4. 4

    Scope every key: read vs write, 401 vs 403

    5 guided steps

    Not every integration needs full access. A merchant's finance team wants a reporting tool that can read order totals and nothing else, so if that tool's server gets compromised, the blast radius is read-only, not 'place unlimited fraudulent orders'. Distinguishing 401 from 403 also gives the client enough information to fix its own configuration without leaking whether a resource exists.

  5. 5

    Operate the key lifecycle: track, revoke, rotate

    5 guided steps

    Keys leak, in a git commit, a Slack message, a support ticket screenshot. The fix cannot require a maintenance window. Because you never store the raw key, you cannot 'change' one in place, so rotation is really issue a new key, migrate traffic, then revoke the old one, and the whole point of last_used_at is proving the old key is genuinely unused before you cut it off.

What's inside when you start

3 starter files, ready to clone
5 guided milestones
5 full reference solutions
8 code blocks explained line-by-line
5 "is it working?" checks
6 interview questions it prepares you for

You'll walk away with

api_keys table with a Drizzle migration, indexed on key_prefix
POST /v1/merchants issuing a merchant plus its first full-scope key, shown exactly once
authenticate preHandler on every v1 route, attaching merchantId, apiKeyId and apiKeyScopes
requireMerchant() and requireScope() helpers used consistently across all route files
Cross-tenant integration test proving a foreign merchant's resource returns 404
DELETE /v1/api-keys/:id revocation and a written zero-downtime rotation runbook

This is portfolio-grade. Build it free.

Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.

Start building