- Two
express-rate-limitmiddlewares that protect whole lanes. - Feature level throttles in Redis, written by hand where a specific abuse case exists (OTP sends, signing links, AI usage).
HTTP limiters
Both are created increateApp from apps/core-api/src/middleware/rate-limit.ts. They share one window (RATE_LIMIT_WINDOW_MS, default 60 seconds) and send the standard RateLimit-* headers (standardHeaders: true, legacyHeaders: false).
Both use the default in-memory store. Counters are per process and reset on restart.
Why the web lane has its own limiter
Every coach’s request reaches the API from the web app’s server, so all of them share one IP. A per IP limit would throttle the whole product at once. The v1 limiter therefore skips web paths:web guard array, after requireServiceAuth and webUserContext. By the time it runs req.auth exists, so the key is the studio and the user:
requireServiceAuth first.
Why trust proxy is exactly 1
req.ip is only the real client address when Express trusts the proxy in front of it. app.set('trust proxy', 1) trusts one hop. Before that setting, every trainee shared the proxy’s address and whole studios received 429 together. true would let a client forge X-Forwarded-For and choose its own bucket.
The public signing exception
The signing page is proxied by the web app, so those requests also arrive from one address.app.ts wraps the v1 limiter:
/v1/public/sign that carries x-service-id skips the limiter, and the signing router then demands a valid signature for it. A direct caller is still counted per IP. The lane’s own throttle applies in both cases.
What is not limited by either
/healthz,/readyz,/metrics.- Everything under
/api(better-auth, oRPC, the payments webhook). better-auth applies its own attempt limits to OTP codes (allowedAttempts: 3).
What a limited client sees
express-rate-limit answers 429 with its own plain text body. It does not go through errorMiddleware, so the response is not in the { ok: false, ... } envelope. Clients should branch on the status code.
Feature throttles in Redis
These live next to the feature. All keys start withperform:. The pattern is the same everywhere: INCR a key, set EXPIRE when the count is 1, compare with a maximum.
Trainee OTP
modules/trainee/trainee.service.ts.
Past the limit
request-otp throws RATE_LIMITED (429) in the standard envelope. The throttle is checked after the account lookup, so the 429 reveals nothing the 404 for an unknown number does not.
Coach login OTP
better-auth’sphoneNumber and emailOTP plugins are configured with a 6 digit code, 300 second expiry and 3 allowed attempts (packages/auth/src/auth.ts).
Onboarding wizard OTP
/v1/public/onboarding/* throttles phone and email codes per phone, per email and per IP, and counts failed verifications. Keys: perform:onboarding-otp-throttle:phone:, perform:onboarding-otp-throttle:ip:, perform:onboarding-otp-fails:, and the perform:onboarding-email-otp-* equivalents.
AutoFit import verification
The import wizard verifies a coach’s phone before pulling their data. Keys underperform:autofit-otp* throttle sends and checks per phone, per user and per studio.
Public form signing
modules/form-sign/form-sign.throttle.ts. A 600 second window, counted per link and per visitor IP for each action:
The link token is hashed before it becomes part of the key, so a Redis key listing never shows a working link. If Redis is down the throttle logs a warning and allows the request. A Redis outage must not stop a trainee from signing a contract.
AI usage caps
These protect cost, not availability. Reaching one returns a friendly message in the chat instead of an HTTP error.
Other soft throttles
Some writes are throttled to save database work, not to stop abuse:Coach.lastActiveAtis written at most once per 5 minutes per coach (in memory,web-context.ts).StudioApiKey.lastUsedAtat most once per 5 minutes per key (LAST_USED_THROTTLE_MS).Client.lastCheckInAtat most once per 15 minutes on/trainee/me(LAST_CHECK_IN_THROTTLE_MS).
Adding a limit
- For a new public route, start with the v1 limiter it already has, then add a Redis throttle keyed by the thing being abused (phone, email, link, token) and by IP.
- Decide what happens when Redis is unavailable. Authentication related throttles should fail closed. Throttles on a task a real user must complete should fail open and log.
- Throw
AppError(ErrorCode.RATE_LIMITED, message)so the client gets the standard envelope. - Hash any secret before using it in a key.