Supabase Auth ownership, session posture, OAuth linking, and deferred controls.
CentraKit uses Supabase Auth for user identity. Application code validates inputs, maps provider errors to domain errors, and avoids returning password material, while Supabase owns password hashing, access-token issuance, refresh-token rotation, and browser/server session cookie format.
supabase/config.toml; mirror this in hosted Supabase settings.gen_salt('bf', 12).@supabase/ssr with Supabase-compatible options.supabase.auth.signOut(). Immediate app-owned token blacklisting is deferred.withApiRoute adapter. User routes accept either a refreshed SSR cookie or an OAuth bearer; a present but invalid bearer never falls back to cookies. Cron and provider-webhook policies retain their own credential contracts.@supabase/server after credential normalization. Its RLS-scoped Supabase client is always safe by default, supabaseAdmin stays lazy and request-memoized, and direct Postgres clients are available only on routes that explicitly opt into postgres: "scoped" | "admin" | "both".Use supabase.auth.getClaims() for server-side checks that only need verified identity claims, such as authenticated state, current user ID (sub), current email, session ID, or coarse role hints. Use supabase.auth.getUser() only when a flow needs the fresh Auth user record, linked identities/providers, email confirmation state, or other Auth-server metadata. Use getSession() only when raw access or refresh tokens are needed.
Hosted Supabase projects should use the new JWT signing keys system with an asymmetric key, preferably ES256. getClaims() can then verify tokens with cached JWKS/WebCrypto instead of putting the Auth server in the hot path. Before rotating away from the legacy JWT secret, confirm no app, API, Edge Function, or integration verifies tokens with the legacy shared secret directly. After rotation, wait at least one access-token lifetime plus safety margin before revoking the previously used legacy key; local config uses a 15-minute JWT lifetime.
permdock.custom_access_token_hook adds PermDock's claims to access tokens: the global roles (user_role, roles), the organization memberships, authz_ver and the datetime_preferences. Keep the hook lean because it runs when Supabase issues or refreshes tokens, claims are stale until the next token refresh, and large claims bloat the chunked SSR cookies, which can bounce post-login redirects back to /login. The memberships stay within PermDock's byte budget and memberships_truncated marks a token that left some out. See RBAC for how the claims are read.
Platform admin impersonation uses better-supabase support sessions; no Auth session is minted for the target. The support-sessions SQL module stores sessions in better_supabase.support_sessions, and start_support_session checks platform.user.impersonate with the admin's own claims, refuses the admin as target, caps the session at 60 minutes and ends the admin's previous session. packages/supabase-runtime/src/support.ts builds bs.support with createServer(betterSupabase, { postgres, support: supportSessions({ store: sqlSupportStore(postgres) }) }) and is the only place that talks to the store.
The session id travels in the HttpOnly bs-support cookie. createRequestContext() reads it after verifying the admin's own token and calls support.current(), which loads the session as the admin, checks the permission again and builds the target's claims through permdock.custom_access_token_hook (the module's claimsHook) plus act: { kind: "support", sub, session_id, reason, read_only }. With a running session the request context:
db over direct Postgres as the target with those claims (postgres.asUser(claims, { readOnly })), so RLS sees the target as auth.uid() and a read-only session fails every write with 25006;dbTransport(db, { schema: "api" }), which calls the functions through db's executor;db.$client and supabase a client without a user token, so Storage and Auth never act as the admin; public Storage URLs still resolve;subjectFromSupabase carries the support actor;sessionCaller and the session in support, for the banner, the stop action and Sentry.A cookie whose session ended or expired resolves to the admin's own context with support.state set to ended, so a stale cookie never acts as the target. The banner then clears the cookie and shows /impersonation/expired. A token with an act claim never holds platform permissions (better_supabase.platform_can) and cannot start another session. start_support_session refuses a target with a system role (public.roles.scope = 'system') through platformRoles.through.where.
The API app composes the same context for cookie-authenticated /api/v1 requests, which the SaaS browser reaches through same-origin rewrites. The route adapter reads the bs-support cookie only when the request carries a cookie session, and composeApiProviderContext builds the context with createCookieRequestContext, so route handlers, the AI chat runtime and its Workflows act as the target and audit the admin. A cookie whose session has ended answers 403 with the problem code SUPPORT_SESSION_ENDED before any route authorization runs. Bearer, API-key and MCP requests never run in a support session, even when the browser also sends the cookie.
The support_session_expiry job runs bs.support.expire() every five minutes. It calls better_supabase.expire_support_sessions() as the service role, which ends each session past its expiry with ended_by expired, so support.ended is recorded for sessions nobody stopped.
The SaaS login and register forms start Google OAuth through Supabase Auth. The hosted Supabase project owns the Google provider credentials; Vercel environment variables may mirror those values but do not configure the hosted Supabase provider by themselves.
Supabase owns account linking. When a confirmed email/password user later signs in with Google and Google returns the same verified email address, Supabase automatically links the Google identity to the existing user. Do not add a public email-existence lookup or app-owned merge path.
For users created through Google first, add email/password login only after the user has proved email ownership and has an active Supabase session. Use Supabase recovery or updateUser({ password }) through the app's existing auth service methods.
For Google Cloud OAuth clients, use the Supabase callback URL as the authorized redirect URI. Local Google OAuth uses Portless aliases under localtest.me because Google rejects .localhost subdomain callbacks.
Register this callback for local testing:
https://supabase.localtest.me:1355/auth/v1/callbackThen start the OAuth dev target:
pnpm dev:oauthSignup and other token-hash emails (confirmation, recovery, magic link) link to the SaaS app's /auth/confirm route, which calls verifyOtp({ token_hash, type }). This avoids the PKCE code exchange at /auth/callback, which requires the verifier cookie set when signUp ran and breaks when the email is opened in a different browser, profile, or in-app webview.
Locally, the confirmation template lives at supabase/templates/confirmation.html and is registered in supabase/config.toml under [auth.email.template.confirmation]. The <a> href uses {{ .RedirectTo }}&token_hash={{ .TokenHash }}&type=signup, where RedirectTo is the emailRedirectTo URL the app sets when calling signUp (https://app.localhost/auth/confirm?redirectPath=...). After editing the template or config, restart Supabase: pnpm exec supabase stop && pnpm supabase:start.
For hosted Supabase projects, mirror this in the dashboard:
https://app.<apex>).https://<prod-host>/auth/confirm (and any preview hosts you use) to Redirect URLs, alongside the existing /auth/callback entries kept for OAuth.supabase/templates/confirmation.html (adjust copy only as needed). The recovery and magic link templates still use the default /auth/callback link until those flows are migrated.OAuth must keep using /auth/callback because providers return a code, not a token_hash.
The repo does not currently maintain app-owned login counters, account lockout tables, unlock tokens, or a token blacklist. Revisit those controls if Supabase-native limits, short JWTs, and generic errors are not enough for the deployed threat model.
Use Supabase's built-in email flows for confirmation and password recovery. If a future security email is not covered by Supabase, add a small Resend boundary with RESEND_API_KEY, a verified transactional sending domain, idempotency keys, and disabled click/open tracking for sensitive transactional email.
Last updated on