Case StudyMay 12, 20269 min read

How We Built Forecourtly to be Secure

A multi-tenant SaaS for UK independent car dealers. One Next.js app serving every dealer's dashboard, every public storefront, on every custom domain. Here are the security decisions that made it possible to ship that without a tenant-leak incident.

At a glance

  • ·Product: Forecourtly, a dealer management system and storefront builder for UK independent car dealers.
  • ·Stack: Next.js 16, Supabase (Postgres + Auth + Storage), Stripe, Anthropic, DataForSEO, fal.ai. Hosted on Vercel (London region).
  • ·Threat model: tenants must never see each other's data. Public storefronts must never render the wrong tenant. AI billing must survive a compromised account.
  • ·Built by: Springcode, the same team behind Vibe App Scanner.

1. The brief

Forecourtly is a single Next.js app that powers two completely different surfaces. The authenticated dashboard that dealers use to run their inventory, generate marketing content, and manage their brand. And every dealer's public storefront, which renders on a Forecourtly subdomain or on the dealer's own custom domain. One codebase, hundreds of tenants, one production database.

Multi-tenant SaaS gets one chance to get tenant isolation right. A single bug that returns the wrong dealer's vehicle listings, or worse the wrong dealer's enquiries with customer phone numbers, kills the product. The job was not just to ship features. It was to build a foundation where the default behavior of every query, every upload, and every API call was tenant-safe.

2. Tenant isolation via Postgres RLS

Row Level Security is the only tenant isolation that survives a developer mistake. Application-layer filters are a single forgotten WHERE dealer_id = ? away from a leak. With RLS, even an attacker who finds an unfiltered query gets back zero rows.

Every business table on Forecourtly has RLS enabled with the same shape:

CREATE POLICY "dealer members can read"
  ON public.vehicles FOR SELECT TO authenticated
  USING (dealer_id IN (SELECT public.current_dealer_ids()));

current_dealer_ids() is a SECURITY DEFINER function that returns every dealer the current authenticated user belongs to. Wrapping it in a function rather than joining dealer_users in every policy buys two things. First, the policy never recurses through dealer_users own RLS. Second, the join happens once per query rather than once per row, which keeps RLS cheap as the table grows.

A handful of tables (the dealers row itself, brand settings, published articles, active vehicles) are also anon-readable, but only when the dealer is status = 'active', and through narrow views that whitelist columns. The default for anything new is denied to anon entirely.

3. The proxy: hostname routing that can't leak

Forecourtly resolves three different surfaces from one app based on hostname. The marketing apex (forecourtly.co.uk). Subdomain storefronts (acme-motors.forecourtly.co.uk). And custom-domain storefronts (a dealer pointing acmemotors.co.uk at us).

The interesting failure mode is what happens when a host looks like a storefront but doesn't resolve. A typo. A canceled dealer. An unprovisioned slug. A naive implementation falls through and renders the marketing site at the dealer's URL, which is confusing at best and brand-damaging at worst. Forecourtly's proxy explicitly rewrites those requests to /storefront-not-found instead of letting them through.

Six subdomains (www, app, admin, api, auth, stripe) are reserved and never resolve as storefronts, even if someone registers a matching slug. The dealer slug regex (^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$) is enforced at the database level via a CHECK constraint, so the application can never write a slug that collides with infrastructure.

4. The only RLS-bypassing surface: Stripe webhooks

There's exactly one place in Forecourtly's codebase that holds the Supabase service role key. The Stripe webhook handler. Stripe events arrive without an authenticated user, so the handler has to bypass RLS to update subscriptions on the right dealer's behalf.

Three things keep that surface small.

First, the service-role client lives in a single file (src/lib/supabase/service.ts) that imports server-only at the top. Any accidental import from a client component fails the build, not silently in production. Second, the webhook verifies Stripe's HMAC signature on the raw body before doing anything else. A forged webhook can't coerce a subscription update. Third, the dealer linkage for each event follows a three-step fallback chain (subscription.metadata.dealer_id, then existing row by stripe_subscription_id, then by stripe_customer_id) so a missing metadata field can't orphan an event or attach it to the wrong dealer.

5. Storage that fails closed

Forecourtly hosts vehicle photos, AI-generated images, brand assets, and article media in four Supabase storage buckets. Every upload has to land under a path whose first segment is a dealer ID the uploader belongs to. The storage RLS enforces this:

CREATE POLICY "dealer members upload images"
  ON storage.objects FOR INSERT TO authenticated
  WITH CHECK (
    bucket_id IN (...)
    AND (storage.foldername(name))[1]::uuid
        IN (SELECT public.current_dealer_ids())
  );

A hand-crafted path that points to another dealer's folder fails with a 403. Reads are intentionally public (so the storefront HTML can serve images without signed URLs), but writes, updates, and deletes are scoped to dealer membership.

All upload paths in the application go through a shared path builder. No feature constructs a storage path by hand. That builder is the one place a future refactor of the path convention has to touch.

6. AI cost protection: rate limits before the bill

Forecourtly leans on three paid APIs: Anthropic for content generation, DataForSEO for keyword research, and fal.ai for image processing. A compromised dealer account, or even a runaway script from a legitimate user, could rack up four figures in API cost in an afternoon.

Every AI-backed server action calls enforceRateLimit(supabase, dealerId, kind) before touching the provider. The limiter is a sliding 24-hour window per dealer, stored in a usage_events table. Anthropic is capped at 50 calls per day per dealer, DataForSEO at 30, fal.ai at 100 (counted per processed image, so a 20-image batch consumes 20).

The cap sits in front of the provider, not after it. A dealer who blows past their daily allowance never pays for the 51st call because the 51st call never goes out.

7. Auth guards as a composable kit

Every authed surface in Forecourtly is gated by one of four server-side functions:

  • requireUser() redirects to /login if no session.
  • requireOnboarded() adds the dealer-exists check.
  • requireFullDms() bounces content-only dealers off storefront surfaces.
  • requireActiveSubscription() hard-locks features when a sub goes canceled, unpaid, or incomplete.

Layout files compose these. No page rolls its own check. When the auth requirements of a route group shift (and they did, twice during the build), the change happens in one place and propagates to every page in that group. That single-point-of-change property is the difference between a security review that takes an hour and one that takes a week.

All four guards are memoized through React's cache so the same auth/dealer/subscription fetch called from a layout, a page, and three components in between collapses to one database round trip per request.

8. What we check after launch

Building secure is one half of the job. Keeping it secure is the other. After launch, Forecourtly is scanned routinely with Vibe App Scanner, the same security tool we built for AI-built apps. The scanner checks:

  • RLS coverage on every public table (no anon SELECT against unprotected tables).
  • Storage bucket exposure (no public-write buckets, no anon list).
  • Exposed API keys in client JS bundles.
  • Security headers (HSTS, CSP, X-Frame-Options, Referrer-Policy).
  • Auth endpoint rate limiting (signup, login, password reset).
  • SSL configuration on every custom domain a dealer points at us.

Most apps we audit at launch fail two or three of these. Forecourtly cleared all six on its first scan, which is what happens when you build the security in from the start rather than bolting it on at the end.

The takeaway

None of the patterns above are exotic. RLS is just Postgres. Reserved subdomains are a five-line check. Per-dealer rate limits are one table and one helper. The work is in deciding that these are the defaults and refusing to ship anything that doesn't fit the pattern.

If you're building a multi-tenant SaaS and want a team that treats tenant isolation as a non-negotiable rather than a nice-to-have, Springcode builds these. If you've already shipped one and want to know whether it's actually as isolated as you think, that's what Vibe App Scanner is for. And if you're a UK car dealer looking for a modern dealer management system and storefront that handles all of the above for you, take a look at Forecourtly.

Scan your own app before you ship

The same checks we run on Forecourtly are available as a self-serve scan. Paste your live app URL and get a security report in 2-3 minutes.

Starts at $9. Results in minutes.

Related Articles