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.
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 ornullisAdmin()— 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.randomBytes—Math.random()is not used for any security value anywhere in the codebase
Authorization
Two independent layers, and you need both:
- Route handlers check ownership explicitly. Every query touching a user-owned row filters by
submitted_by,user_idorowner_user_idtaken from the validated session — never from the request body or the URL. - Row Level Security is enabled on every table, with
TO authenticatedpolicies on anything private.
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
constructEventagainst the raw request body, and the handler returns400on a signature failure. A missingSTRIPE_WEBHOOK_SECRETthrows rather than skipping the check. - Store orders are idempotent — a unique constraint on
stripe_session_idmeans a Stripe retry cannot create a second order. - Inventory is decremented through the atomic
decrement_inventoryRPC, 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_statusandapproved, which are hardcoded tofalseon submission and set only by the webhook or an admin action.
File uploads
Both upload routes apply the same chain, in this order:
- Rate limit
- Authenticated session
- MIME type against an allowlist (JPEG, PNG, WebP)
- Magic-byte validation of the actual file content, including
WEBPat offset 8 — not just theRIFFprefix, which WAV and AVI also have - Extension cross-checked against the declared type
- 1 MB size cap
- 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:andhttps: - 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
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
dangerouslySetInnerHTMLexcept for structured-data blocks. - JSON-LD is serialised with
jsonLdSafe()fromlib/seo.ts, notJSON.stringify. PlainJSON.stringifydoes not escape<, and the HTML parser ends a<script>element at the first</scriptregardless 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-srcis an explicit allowlist, so an injected script cannot exfiltrate to an arbitrary host.
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.
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.
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