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.
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:
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
Issue keys the safe way
6 guided stepsThe 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
Authenticate every request
6 guided stepsA 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
Scope every query by merchant
6 guided stepsAuthentication 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
Scope every key: read vs write, 401 vs 403
5 guided stepsNot 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
Operate the key lifecycle: track, revoke, rotate
5 guided stepsKeys 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
You'll walk away with
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