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 |
|---|---|
|
Creates the Supabase user, then |
|
Signs in and sets the session cookies on the response |
|
Clears the session and the |
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.
-
Bypass —
/.well-knownand/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. -
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. -
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. -
Protect — no session, and the path is not
/login,/signup,/auth/callback,/docsor/api→ redirect to/login?redirectedFrom=<path>. -
Bounce — a session on
/loginor/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.sqlblocks 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.