Security hardening
Rate limiting, security headers, and bot detection
Overview
The application implements multiple security layers that degrade gracefully — everything works in local dev without env vars.
Security headers (Nosecone)
Applied to every response via middleware (apps/web/proxy.ts). Always active, no env vars required.
| Header | Value | Protection |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains | Forces HTTPS |
X-Frame-Options | SAMEORIGIN | Clickjacking |
X-Content-Type-Options | nosniff | MIME sniffing |
Referrer-Policy | no-referrer | Referrer leaks |
Cross-Origin-Opener-Policy | same-origin | Process isolation |
Cross-Origin-Resource-Policy | same-origin | Cross-origin loads |
Cross-Origin-Embedder-Policy | require-corp | Cross-origin embeds |
X-XSS-Protection | 0 | Disables buggy legacy filter |
CSP is disabled (needs per-app configuration).
Rate limiting (Upstash Redis)
Requires UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN. Without them, all rate limiting silently skips.
The limiters
Defined once in packages/rate-limit/limiters.ts, each with its scope and the reason it exists. That table is the source of truth; this is a snapshot.
| Limiter | Limit | Keyed on |
|---|---|---|
auth | 5 / 60s | email or IP |
authStrict | 3 / 60s | email or IP |
api | 30 / 60s | IP |
chat | 5 / 60s | sender user id |
helpFeedback | 20 / 60s | IP |
ai | 10 / 60s | user id |
searchAsk | 10 / 60s | IP |
upload | 60 / 60s | user id |
mutation | 20 / 60s | user id |
authStrict is tighter than auth because each of those actions sends mail to an address the caller chose. chat is an anti-spam bound on blasting sellers, not a typing-speed budget.
Action clients and inline rate limiting
Server actions use clients from @repo/lib/action-clients for auth, with rate limiting applied inline per action:
publicActionClient— no auth (read-only work: shipping quotes, postal lookup)authActionClient— session check;{ session, userId }in contextstaffActionClient(capability)—authActionClientplus the staff table in@repo/supabase/staff(admin: every capability; moderator: members, listings, community, dispute triage); writes oneuser_activity_logsrow per successful act, against the subject the action names withctx.auditstaffReadActionClient(capability)— the same gate with no ledger row, for the few staff reads not yet on a route handler
Rate limiting is applied at the top of each action with the right limiter from @repo/rate-limit/limiters, so the limit is visible where the action is, not hidden in a wrapper.
Socket server rate limiting (in-memory)
The socket server uses an in-memory sliding window limiter. Single Fly.io instance means no Redis needed.
| Event | Limit | Window |
|---|---|---|
send_message | 30 | 60s |
typing | 10 | 10s |
heartbeat | 5 | 30s |
join_conversation | 10 | 60s |
On rate limit exceeded, the server emits { type: "RATE_LIMITED", message: "Too many requests" } and drops the event.
Arcjet bot detection (dormant)
Wired into /api/v1/checkout and /api/v1/withdraw. Activates when ARCJET_KEY env var is set. Adds Shield (common attack patterns) + bot detection.
Environment variables for deployment
| Variable | Required | Purpose |
|---|---|---|
UPSTASH_REDIS_REST_URL | Optional | Redis for rate limiting |
UPSTASH_REDIS_REST_TOKEN | Optional | Redis auth token |
ARCJET_KEY | Optional | Bot detection (starts with ajkey_) |
Graceful degradation
| Feature | Without env vars | With env vars |
|---|---|---|
| Security headers | Always active | Always active |
| Server action rate limiting | Silently skips | Active |
| API route rate limiting | Silently skips | Active |
| Socket rate limiting | Always active (in-memory) | Always active |
| Arcjet bot detection | Silently skips | Active |