API security
The controls every API we build must have: sign-in, access checks, rate limits, input validation, internal routes, webhooks and logging. Read it before you add an endpoint.
How it works
How to build it
Sign-in
- Passwords hashed with Argon2id. Never stored or logged in any other form.
- Tokens carry an audience: app users and staff get different audiences, and staff routes refuse app tokens.
- Short-lived access tokens, refresh tokens that can be revoked, and "sign out of all devices".
- One-time codes expire quickly, are single use, and are rate limited per account and per IP.
Lockout and rate limits
| Control | Our default |
|---|---|
| Global per-IP limit | Around 500 requests a minute, tuned per service |
| Sign-in attempts | Per account and per IP, sliding window, then a temporary lock |
| Staff sign-in | Stricter, for example 10 per 5 minutes, plus TOTP |
| One-time code requests | Per account, with a resend cooldown |
| Webhook routes | Exempt from per-IP limits, protected by signatures instead |
Access checks on every object
Every read or write by id checks that the object belongs to the caller, or that the caller has a role that allows it. Check in the use-case, not only in the UI.
const order = await ordersRepo.get(tx, input.orderId);
if (!order || order.userId !== ctx.user.id) return err(notFound('order'));
Return "not found" rather than "forbidden" for objects the caller may not see, so ids cannot be probed.
Internal routes and API docs
- Internal endpoints (service-to-service) are not on public hosts. Block them at the WAF or load balancer for any source outside our own network, and require an internal key in the app.
- Turn off generated API docs (
/openapi.json,/docs) in production, or serve them only internally. Public docs list every route, including the ones you meant to hide. - Staff operations are served on a separate host or refused on the public one.
Validation
- Every input parsed with a schema, including id formats.
- Database errors caused by bad input are client errors (4xx), not server errors.
- No SQL built by string concatenation. Use parameterised queries or the ORM.
Logging
- A redaction list for headers, query strings and bodies:
authorization,cookie,token,x-internal-key,password,otp. - A test that logs a request with each secret and asserts none appear.
- See CloudWatch and account security for the log queries that find leaks.
Rules
The application's database user owns only its own schema and has no admin rights. It can never be a superuser, and can never run server programs or read files. A database superuser turns one injection bug into control of the server.
An internal API secret is never the only control. Internal routes are also unreachable from the internet. When a secret leaks, the network rule still holds.
Verbose errors and development mode are off in production. Check the environment variable on every deployed service.
Mistakes we made
- Internal routes were discoverable from public API docs, and scanners tried them thousands of times. They failed because each route required an internal key, but the docs should never have been public. Fixed with a WAF rule and docs turned off.
- Bad ids reached the database and were counted as server errors, so a scanner could create a fake error spike.
- Tokens and an internal key were written to logs for weeks. Fixed with a redaction list, a test, and rotating the key.
- A platform API ran in development mode in production, returning detailed errors.
Checklist
- Argon2id passwords; tokens with audiences; refresh tokens revocable.
- Rate limits and lockout on every sign-in and code request.
- Ownership check on every object by id.
- Internal routes blocked at the edge and keyed in the app.
- API docs off in production.
- Schema validation on every input; parameterised queries only.
- Database user has least privilege.
- Redaction list with a test.