Skip to main content

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​

ControlOur default
Global per-IP limitAround 500 requests a minute, tuned per service
Sign-in attemptsPer account and per IP, sliding window, then a temporary lock
Staff sign-inStricter, for example 10 per 5 minutes, plus TOTP
One-time code requestsPer account, with a resend cooldown
Webhook routesExempt 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​

Rule

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.

Rule

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.

Rule

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.