Authentication and route protection
Supacharger uses Supabase Auth with @supabase/ssr and the Next.js 16 proxy.ts convention. Proxy refreshes the session and makes optimistic page-routing decisions; it is not the authoritative authorisation boundary. Route Handlers, Server Functions, data services, grants, and Row Level Security (RLS) must validate access independently.
Server-side identity checks
The Proxy always calls supabase.auth.getClaims() exactly once. It verifies the JWT signature and expiry and normally avoids an Auth-server round trip when asymmetric signing keys are enabled. This is a fixed security invariant rather than an application configuration choice. AUTH_SESSION.ALLOW_ANONYMOUS_USERS remains application-configurable.
Before it creates that request-scoped Auth client, Proxy strips known password, email, MFA and organisation-registration fields from query strings on authentication routes and redirects to the sanitised URL. Safe return parameters remain intact, and a valid lang handoff is captured before removal. This is defence in depth for a form rendered before client hydration; Auth forms must still submit with POST and validate again at the protected server boundary.
Managed authentication contract tests remain portable across applications: they validate supported configuration shapes, exact managed route/form behaviour and the semantic presentation interface, while each application's selected passwordless mode, confirmation policy, sidecar contents, CSS values and root layout stay developer-owned.
Do not trust getSession().user for server-side authorisation. Supacharger excludes anonymous Supabase Auth users by default even though their database role is authenticated; an application must deliberately opt in to accept them.
Proxy response invariant
The request-scoped Supabase client runs exactly once per matched request. When it refreshes or clears a session, Supacharger carries all of the following into every continuation and redirect:
- mutated request cookies for downstream Server Components;
- response
Set-Cookievalues for the browser; and Cache-Control,Expires, andPragmasupplied by@supabase/ssr.
Do not create a redirect, rewrite, error response, or replacement NextResponse.next() without copying this session response state. The anti-cache headers prevent an intermediary from caching a response containing another user’s auth cookies.
Route policy
Static page policy remains application-owned in src/supacharger.config.ts:
AUTH_ONLY_APP: true,
PATH_AUTH_GUARD: {
UNAUTHED_USER: {
ALLOWED: ['/', '/account/login', '/auth/callback'],
DISALLOWED: [],
},
AUTHED_USER: {
DISALLOWED: ['/account/login'],
},
},
Keep the leading slash. /:path* denotes a trailing wildcard. Proxy redirects are a usability feature only: API routes return their own 401 or 403, Server Functions validate their caller, and RLS remains the final data boundary.
The protected route-policy implementation supports exact literals, one-segment parameters such as /organisations/:handle, and trailing wildcards such as /docs/:path*. Unauthenticated protected API paths receive a JSON 401; page paths redirect to the configured login destination. Use copySupabaseResponseState whenever an application adds a redirect or replacement response.
The Proxy performs no database RPCs. The protected authenticated layout calls the shared server-access helper after routing, where it can enforce profile completion and billing policy with the current request's claims. Routes declared in PATH_AUTH_GUARD.UNAUTHED_USER.ALLOWED remain public for authenticated users with incomplete profiles and bypass this protected layout. Product-specific membership, organisation, or permission checks extend that server boundary; they do not belong in the Proxy hot path.
Authentication journey
Supacharger owns the public route scaffolds and accessible form structure:
| Purpose | Managed route |
|---|---|
| Sign in | /account/login |
| Create account | /account/create |
| Passwordless entry/status | /account/login-magic |
| Request password reset | /account/reset-password |
| Set a new password (recovery session required) | /account/reset-password/new |
| OAuth/recovery PKCE callback | /auth/callback |
| Email/OTP confirmation | /auth/confirm |
| Safe authentication error | /auth/error |
These pages live under src/app/(supacharger)/(unauthenticated)/. Do not create another page with the same public URL under (project), because two route groups cannot own the same Next.js route. The managed shell imports AuthSidecar and AuthMobileBrand from src/supacharger.adapters/auth/auth-sidecar.tsx. Managed forms expose stable sc-auth-* hooks, and src/styles/supacharger-auth.css supplies their product presentation. The CLI installs missing starter versions of those two developer-owned files and preserves them on later updates.
The shared clean-room journey is shipped to Core, SnapScreen, and Wakekeeper, using SnapScreen as the approved visual and interaction basis. Those projects preserve its strong heading treatment, progressive provider-to-email transition, dark auth surfaces, branded primary actions, and matching password-reset presentation. Auth buttons and input fields use the same 56 px control height and 20 px by 12 px internal padding, preventing primitive component defaults from changing their relative dimensions. The password-requirements checklist is anchored below its input as a floating panel: showing it does not change form height, move the confirmation field, or cover the password input. Signed-in password and confirmation fields remain on separate rows at every viewport width. Below the lg breakpoint, the hidden left panel hands logo presentation to a single logo above the login or signup content. At lg and wider, the inline logo is hidden and the left-panel logo is shown. Project copy, colours, imagery, enabled methods, CSS, and sidecar adapters remain developer-owned.
The journey supports password sign-in/sign-up, exclusive passwordless OTP or link modes, dynamically sized OTP entry, resend cooldown, correct browser autocomplete purposes, and provider buttons. Passwordless sign-in and password-signup verification keep distinct wording and verification purposes. A safe relative next path survives route switches, sends, resends, and verification. The bare root destination / is implicit, so redirects omit the redundant ?next=%2F; a root path with a query string or fragment remains explicit. Password, link, and OTP submissions retain independent busy indicators. New shared states extend the SnapScreen-based presentation rather than replacing it with a generic form. Specdrive consumes the same routes and behaviour through its authorised developer-owned presentation; its old /auth/login, /auth/sign-up, /auth/forgot-password, and /auth/update-password pages remain for one documented release as redirects that retain only a validated internal next destination.
AUTHENTICATION: {
EMAIL_PASSWORD: { SIGN_IN: true, SIGN_UP: true },
PASSWORDLESS_EMAIL: {
SIGN_IN: 'otp', // 'disabled' | 'otp' | 'link'
SIGN_UP: 'disabled',
OTP_LENGTH: 6, // 6 through 10
},
SIGN_UP_EMAIL_VERIFICATION: 'otp', // also supports 'otp-and-link'
MFA_TOTP: { REQUIRED_FOR_SIGN_IN: false },
},
UI and server actions enforce these options. Password signup crosses a Zod-validated Server Action boundary before calling Supabase; the browser form is not the trust boundary. Passwordless sign-in calls signInWithOtp() with shouldCreateUser: false; entered codes verify with type: 'email'. Its hosted Magic Link or OTP template contains either {{ .Token }} for OTP mode or the login link for link mode, never both. OTP_LENGTH drives the field count, validation, and sign-in copy. Password signup remains independent: its confirmation code verifies with type: 'signup', and SIGN_UP_EMAIL_VERIFICATION controls the Confirm signup template. Password signup may use otp-and-link; consuming either credential invalidates the other. Logout posts once through the shared server route so SSR cookies are cleared before navigation. The POST response supplies a safe relative redirectTo path; direct GET logout uses a relative Location header with status 303. The browser resolves either path against the origin it opened, so neither flow depends on the configured Site URL for its final navigation.
Changing a signed-in user's password posts to /api/account/update-password. The protected handler strictly parses the current/new password payload, verifies the user, reauthenticates with Supabase Auth signInWithPassword, applies the configured password-strength policy, and calls updateUser. Supacharger does not expose a password-comparison database RPC.
The OTP and link in one signup email are two ways to complete the same one-time verification. If someone enters the OTP and then clicks the link, Supabase rejects the consumed link as expired or invalid. Supacharger handles that expected state without exposing a generic error page: it checks the current Auth user, sends a confirmed signed-in browser to the safe configured app destination with an explanatory toast, and sends a browser without a session to the configured login page with an expired/already-used notice. The redirect never retains the token hash or accepts a protocol-relative next value. Keep AuthConfirmationNotice mounted in the application root layout so one-time confirmation notices are shown and removed from the URL. A local supabase/config.toml or template does not update hosted Auth settings or templates.
Passwordless link mode returns through /auth/confirm; OTP mode verifies directly from the login form. /auth/callback exchanges one-use PKCE codes for OAuth and password recovery. Recovery links carry flow=recovery; after exchange, Supacharger verifies the JWT recovery authentication method before opening /account/reset-password/new. A local supabase/config.toml or template does not update hosted Auth settings or templates.
See Supabase Auth coverage for a tick-and-dash comparison of email, OAuth, passkey, SAML, Web3, anonymous, MFA, and OAuth-server support, plus every social provider exposed by Supacharger. Phone authentication is intentionally excluded from that page.
Password sign-in navigates directly to the login destination, while the canonical protected server layout independently enforces configured profile and billing access. Proxy remains claims-only. APIs and Server Actions outside that layout still require their own entitlement checks. Read Login redirects and subscription paywalls for the exact precedence and server-side enforcement requirements.
Branding, translated copy, additional authentication fields, and product onboarding stay in developer-owned paths. Organisation creation or switching occurs only after authentication through api.organisations(input_payload), which derives user/session identity from the JWT; an organisation handle is not stored as arbitrary user metadata.
Mobile verification callbacks
MOBILE_DEEP_LINKING configures the generated iOS Universal Link and Android App Link association endpoints. Use it when magic links, link-based confirmation, or password reset should return to an installed mobile app. Email OTP verification remains an in-app code-entry flow and does not require a deep link.
The callback must return to the same client that initiated PKCE. A wrapped web app reloads the verified HTTPS URL in its persistent authentication web view; a fully native app completes the exchange through its native Supabase client. See Mobile login verification with deep links for the complete configuration, Xcode, Android manifest, callback, and verification procedure.
Passwords and reset
Configure the same password minimum and complexity in Supacharger and the Supabase Auth provider settings. PASSWORD_CUSTOM_REGEX is enforced by both the browser checklist and the server-side recovery/update boundary; it does not change Supabase Auth’s own password policy. Password-reset email redirects to /auth/callback?flow=recovery; allow /auth/callback in each hosted Supabase project and set NEXT_PUBLIC_SITE_URL correctly. The callback exchanges the PKCE code once on the server, and /api/account/recover-password requires a verified recovery AMR before applying the new password.
NEXT_PUBLIC_SITE_URL is required and is the single runtime source for the application's canonical origin. Supacharger validates it with getSiteUrl() and builds authentication destinations with getURL(path). Do not add localhost or production fallbacks in source code: a missing value fails clearly so redirects cannot silently target the wrong application or port. A local Supabase config.toml can reference the same environment variable, but hosted Supabase URL Configuration remains a separate dashboard setting.
Use the hosted setup wizard to configure the environment variables, Site URL, exact callback allow-list, email policy, SMTP, and templates in the required order.
Roles and custom claims
The canonical migration creates private app.user_roles data and app.custom_access_token_hook(jsonb). The hook adds a top-level user_role claim whenever Supabase Auth issues or refreshes a JWT. The browser cannot write the role table or execute the hook.
See Roles and custom claims for setup, role assignment, RLS examples, token-refresh behaviour, and the Specdrive-style active-organisation extension.
CLI workflow
Auth hooks and role changes are schema changes. Create and verify them through the project-scoped Supabase CLI:
npx supabase migration new describe_the_change
npx supabase db reset
npx supabase db lint --local
npm run generate-types
Keep [auth.hook.custom_access_token] enabled in supabase/config.toml. For a hosted environment, select app.custom_access_token_hook in Authentication → Hooks after deploying the migration. Preview linked database changes before applying them; never treat a local config.toml edit as proof that a hosted dashboard setting changed.
The first-deployment sequence and its verification checks are documented in Connect hosted Supabase and Verify production.