Skip to content

[SaaS #2] Foundation: cross-subdomain auth cookies + middleware subdomain extraction #88

@macwilling

Description

@macwilling

Overview

The two foundational code changes that everything else depends on:

  1. Make Supabase auth cookies work across all *.taskflow.com subdomains
  2. Teach the middleware to identify the current tenant from the Host header instead of the URL path

Context

Currently all routing is path-based (/portal/[tenantSlug]). After this issue, the tenant is always known from the subdomain, and the middleware injects it as a header for downstream use. This is the critical enabler for all other SaaS issues.

Changes

lib/supabase/server.ts

Set cookie domain on all get/set/remove operations in createServerClient:

const cookieDomain = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; // ".taskflow.com" in prod, undefined in dev
// In set/remove cookie handlers:
cookieStore.set({ name, value, ...options, ...(cookieDomain ? { domain: cookieDomain } : {}) });

lib/supabase/middleware.ts

Same cookie domain injection in the updateSession() cookie handlers.

lib/supabase/client.ts

Browser client handles cookies automatically via Supabase SSR — no change needed. The server-side cookie with .taskflow.com domain is readable by all subdomains.

proxy.ts

Add subdomain extraction and header injection:

// New utility function:
function getTenantSlugFromHost(host: string): string | null {
  const baseDomain = process.env.NEXT_PUBLIC_BASE_DOMAIN ?? "localhost";
  if (!host.includes(baseDomain)) return null; // localhost or unknown host
  const subdomain = host.split(".")[0];
  if (subdomain === "www" || subdomain === baseDomain) return null;
  return subdomain; // e.g. "acme" from "acme.taskflow.com"
}
  • In the proxy handler: const tenantSlug = getTenantSlugFromHost(request.headers.get("host") ?? "");
  • Inject into every outgoing response/request: requestHeaders.set("x-tenant-slug", tenantSlug ?? "");
  • Replace all pathname.split("/")[2] slug extraction with tenantSlug from above
  • Admin route guard: user's app_metadata.tenant_slug must match tenantSlug from subdomain
  • Portal route guard: same check — user's tenant_slug must match subdomain slug
  • Remove ALLOW_REGISTRATION guard (registration will be open after Phase 6c: Portal auth — Google OAuth #4)

New env vars (add to .env.example)

NEXT_PUBLIC_BASE_DOMAIN=taskflow.com   # base domain (no protocol)
NEXT_PUBLIC_COOKIE_DOMAIN=.taskflow.com  # leading dot for wildcard

Leave both unset for local dev (cookies scope to localhost, no subdomain needed).

Key Files

  • lib/supabase/server.ts
  • lib/supabase/middleware.ts
  • proxy.ts
  • .env.example

Testing

  • In dev: subdomain routing won't apply (localhost has no subdomains). Test via lvh.me trick or add 127.0.0.1 acme.lvh.me to /etc/hosts and set NEXT_PUBLIC_BASE_DOMAIN=lvh.me.
  • Unit test: getTenantSlugFromHost() with various inputs (subdomain, base domain, localhost, www)
  • Verify session cookie is readable on a different subdomain (integration test)

Depends On

Issue #87 (infra) — specifically the NEXT_PUBLIC_COOKIE_DOMAIN env var must be set in Vercel.

Blocks

Issues #3, #4, #5.

Metadata

Metadata

Assignees

No one assigned

    Labels

    saas-migrationTrue multi-tenant SaaS migration

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions