Skip to main content

Built-in Protections

What DirectoryLaunch defends against out of the box — session validation, RLS, webhook signatures, upload validation, SSRF guards, rate limiting and output encoding — and where the boundaries are.

This page documents the defences that are already in the code, so you know what you inherit and, just as importantly, what you must not accidentally remove.

Everything here was verified in a full security audit of the codebase in August 2026.

Authentication and sessions

Sessions are validated server-side on every request through supabase.auth.getUser(), which checks the JWT against Supabase's auth server.

Never switch to getSession()

getSession() reads the cookie without server validation and can be spoofed. Every helper in lib/supabase/auth-helpers.ts — including the getSession alias exported from lib/supabase/auth.ts — resolves to getUser(). If you add a new route, use those helpers.

  • getServerSession() / getCurrentUser() — validated user or null
  • isAdmin() — validated user plus a database check
  • Verification codes (listing claims, quote requests) are stored as SHA-256 hashes and compared with crypto.timingSafeEqual, with a TTL, an attempt cap and a resend cooldown
  • Every token, code and identifier is generated with crypto.randomBytesMath.random() is not used for any security value anywhere in the codebase

Authorization

Two independent layers, and you need both:

  1. Route handlers check ownership explicitly. Every query touching a user-owned row filters by submitted_by, user_id or owner_user_id taken from the validated session — never from the request body or the URL.
  2. Row Level Security is enabled on every table, with TO authenticated policies on anything private.
Why both layers matter

The db layer in lib/supabase/database.ts uses the service-role key, which bypasses RLS entirely. That means RLS is not your first line of defence — it is the backstop that protects you from the publishable key, which ships in your client bundle. A missing ownership check in a handler is a real vulnerability even when the table has policies.

Admin authentication for the API accepts either a validated admin session or a CRON_SECRET bearer token. That check lives in lib/auth/cron.ts and fails closed: if CRON_SECRET is unset or shorter than 32 characters, no bearer token is ever accepted. The comparison is constant-time.

Payments

  • Stripe webhooks are verified with constructEvent against the raw request body, and the handler returns 400 on a signature failure. A missing STRIPE_WEBHOOK_SECRET throws rather than skipping the check.
  • Store orders are idempotent — a unique constraint on stripe_session_id means a Stripe retry cannot create a second order.
  • Inventory is decremented through the atomic decrement_inventory RPC, never a read-then-write.
  • Prices are always resolved server-side from the database, in integer minor units. Nothing about money is ever read from the request body — including payment_status and approved, which are hardcoded to false on submission and set only by the webhook or an admin action.

File uploads

Both upload routes apply the same chain, in this order:

  1. Rate limit
  2. Authenticated session
  3. MIME type against an allowlist (JPEG, PNG, WebP)
  4. Magic-byte validation of the actual file content, including WEBP at offset 8 — not just the RIFF prefix, which WAV and AVI also have
  5. Extension cross-checked against the declared type
  6. 1 MB size cap
  7. A server-generated uuidv4() object key — no part of the storage path comes from the client

SVG is deliberately not allowed: an SVG served inline from your own domain is stored XSS.

Outbound requests (SSRF)

Any URL that comes from a user goes through safeFetchText() in lib/safe-fetch.ts, which:

  • allows only http: and https:
  • resolves the hostname and refuses loopback, private, link-local, CGNAT and multicast ranges — including 169.254.169.254, the cloud metadata endpoint that turns SSRF into full infrastructure compromise
  • follows redirects manually, re-validating the target on every hop, because a public host can redirect to 127.0.0.1
  • caps the response body and the total time
  • never reflects the underlying network error back to the caller, which would make the endpoint a scanning oracle
Use safeFetchText for any user-supplied URL

Checking the scheme is not enough. If you add a feature that fetches a URL a user provided — importing from a URL, an avatar fetch, a link preview — route it through safeFetchText().

Output encoding and XSS

  • React escapes text by default; the codebase avoids dangerouslySetInnerHTML except for structured-data blocks.
  • JSON-LD is serialised with jsonLdSafe() from lib/seo.ts, not JSON.stringify. Plain JSON.stringify does not escape <, and the HTML parser ends a <script> element at the first </script regardless of JSON context — so a listing name could otherwise break out of the block.
  • User-supplied listing fields are sanitised on both the create and the edit paths.
  • Security headers are set globally in next.config.ts: CSP, HSTS, X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy. connect-src is an explicit allowlist, so an injected script cannot exfiltrate to an arbitrary host.
One known CSP limitation

script-src still includes 'unsafe-inline', because Next.js injects inline bootstrap scripts and removing it requires plumbing a per-request nonce through middleware. XSS containment therefore relies on the output encoding above. If you add a nonce, tighten this.

Rate limiting

lib/rate-limit.ts provides buckets for general, auth, submission, upload, admin and analytics. The client key is derived only from platform-set headers (x-vercel-forwarded-for, cf-connecting-ip, or the right-most x-forwarded-for hop) — never from anything the caller can freely rotate, such as the User-Agent. In production, a request with no trustworthy client identity is refused rather than allowed through unlimited.

The default limiter is in-memory

It does not persist across restarts and does not span serverless instances, so on Vercel the effective limit is roughly N × configured. It raises the cost of abuse but is not a hard ceiling. For anything customer-facing at scale, swap in @upstash/ratelimit with Upstash Redis, and add Vercel Firewall rules as a platform-level second layer. Sign-in throttling is enforced by Supabase Auth — configure it in the dashboard, see the go-live checklist.

Inbound webhooks

Every inbound webhook requires a mandatory shared secret and refuses the request when it is unset — Stripe (signature), Telegram (x-telegram-bot-api-secret-token) and Printful (x-printful-secret). Telegram additionally checks the chat id against an allowlist that fails closed when empty.

Email

Unsubscribe links are HMAC-signed and bound to the address, and the actual suppression happens on POST, not GET. That prevents two separate problems: anyone suppressing anyone else's address, and mail-security scanners unsubscribing real recipients by prefetching links.

Error handling

Client-facing errors return generic messages; the full error goes to the server log only. Internal messages leak stack traces, hostnames and driver details, and in a URL-fetching endpoint they double as an internal-network oracle.


What is not covered by the code

These are yours to configure — see the Go-Live Security Checklist:

  • Supabase Auth attack protection (leaked passwords, CAPTCHA, MFA, sign-in rate limits)
  • RLS actually being applied to your project
  • Storage bucket visibility
  • SPF / DKIM / DMARC / CAA on your domain
  • Secret generation and rotation
  • Keeping dependencies patched