Authentication

Identity is Supabase Auth. Sessions are HTTP-only cookies, refreshed automatically.

Why the API, not the browser client

Signup, sign-in and sign-out go through the application’s own endpoints rather than supabase.auth. on the client, so the *server writes the session cookies through the route-handler Supabase client. The register endpoint also has to create a Profile, a Subscription and a SubscriptionUser in the same operation — work the browser client cannot do.

Endpoint Effect

POST /api/auth/register

Creates the Supabase user, then Profile + Subscription + SubscriptionUser (role owner). Checks password strength (lib/auth/password-strength.ts) and the email domain

POST /api/auth/signin

Signs in and sets the session cookies on the response

POST /api/auth/signout

Clears the session and the active_subscription_id cookie

Call them with credentials: "same-origin". After sign-out, redirect to /login and call router.refresh() so server components re-render without the session.

Calling supabase.auth.signInWithPassword directly still works, but skips the profile/workspace creation and the cookie clearing, so prefer the endpoints.

Flow

sequenceDiagram
  participant U as User
  participant C as Client
  participant API as /api/auth/*
  participant SA as Supabase Auth
  participant MW as middleware.ts

  U->>C: submit credentials
  C->>API: POST /api/auth/signin
  API->>SA: signInWithPassword
  SA-->>API: session
  API-->>C: Set-Cookie (HTTP-only)
  C->>MW: GET /campaigns
  MW->>SA: getSession + getUser
  MW->>MW: isAllowedPayabilityEmail(email)?
  MW-->>C: allow, or redirect to /login

Middleware

middleware.ts runs on every non-excluded path.

  1. Bypass — /.well-known and /well-known (workflow runtime callbacks) return immediately, as do /icons/ and /assets/. Public assets must bypass auth or the login page cannot load its own logos.

  2. Resolve config — resolveSupabaseAnonEnv(). When credentials are absent the request is allowed through, because auth cannot be enforced and failing every request would make a misconfigured environment unreachable.

  3. Domain gate — when a session exists, the user’s email is checked against isAllowedPayabilityEmail(). A non-Payability address is signed out and redirected to /login?error=forbidden_domain.

  4. Protect — no session, and the path is not /login, /signup, /auth/callback, /docs or /api → redirect to /login?redirectedFrom=<path>.

  5. Bounce — a session on /login or /signup → redirect to /.

Matcher

matcher: [
  "/((?!_next/static|_next/image|favicon.ico|icons|assets|images|fonts|supabase/functions|api/webhooks|\\.well-known|well-known).*)",
]

api/webhooks is excluded so provider callbacks are never redirected. /api is excluded from the redirect rule inside the middleware too — API routes return 401 rather than a redirect, because a fetch cannot follow one usefully.

The email-domain gate

lib/auth/allowed-email-domain.ts:

export function isAllowedPayabilityEmail(email: string): boolean {
  const normalized = email.trim().toLowerCase()
  const at = normalized.lastIndexOf("@")
  if (at <= 0 || at === normalized.length - 1) return false
  const domain = normalized.slice(at + 1)
  return domain === "payability.com" || domain.endsWith(".payability.com")
}

Enforced in two places:

  • Middleware, on every request with a session — so a user whose access is revoked is signed out on their next page load, not at their next login.

  • A Supabase auth hook — prisma/sql/auth_hook_before_user_created_payability.sql blocks creation of a disallowed user at the source, which also covers OAuth sign-ups that never touch the register endpoint.

PAYABILITY_EMAIL_FORBIDDEN_MESSAGE is the user-facing string.

Server-side checks

Tenant-scoped route:

import { resolveSubscription } from "@/lib/auth/subscription"

const result = await resolveSubscription()
if (!result.success) {
  return NextResponse.json({ error: result.error }, { status: result.status })
}
const { subscriptionId } = result.context

Auth-only route:

import { getSupabaseServerClient } from "@/lib/supabase/server-client"

const supabase = await getSupabaseServerClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })

Prefer resolveSubscription() — it authenticates and resolves the tenant in one call, and stamps Sentry with the user and workspace.

Client-side

import { getSupabaseBrowserClient } from "@/lib/supabase/browser-client"

const supabase = getSupabaseBrowserClient()
const { data: { user } } = await supabase.auth.getUser()

Most components should not need this — GET /api/me via the useMe() hook already provides the user, profile and workspace list.

Google OAuth

SUPABASE_GOOGLE_CLIENT_ID and SUPABASE_GOOGLE_CLIENT_SECRET configure Google sign-in, with /auth/callback handling the redirect. The domain gate applies to OAuth users identically — the auth hook blocks creation, and middleware signs out anyone who slips through.

Error responses

{ "error": "Unauthorized" }   // 401 — no session
{ "error": "Forbidden: not a member of this subscription" }   // 403
{ "error": "No profile found for user" }   // 403
{ "error": "No subscription found for user" }   // 403