THE WORLD IS NOT STANDING STILL.RSS
BIG CHANGE.
BIG CHANGE, one tap away
BIG CHANGE, one tap away

Make room for the big picture.

Add BIG CHANGE to your home screen or desktop. It opens in its own window, with the latest stories when you’re online.

iPhone & iPad
Open bigchange.ai in Safari. Tap Share, then Add to Home Screen. Turn on Open as Web App if shown, then tap Add.
Android
Open bigchange.ai in Chrome. In the browser menu, choose Install app or Add to Home screen.
Desktop
In Chrome or Edge, use the install icon in the address bar or the browser’s app menu. In Safari on Mac, choose File → Add to Dock.

Markdown edition

# Add sign-in and protected data to an existing React app

> Use a coding agent to add Supabase email authentication to an existing React/Vite app, then check real callbacks, enrollment and per-user data access before release.

By BIGCHANGE.AI Editorial

Published: 2026-10-22T16:07:38.642Z
Updated: 2026-10-22T16:07:38.642Z
Canonical: https://bigchange.ai/blog/react-supabase-email-auth-protected-data-guide

![Conceptual charcoal illustration of three unbranded phones in separate slots of a small device rack; two show only blank gray blocks and the third screen is dark.](https://bigchange.ai/api/media/file/react-supabase-auth-test-devices-hero-v2.png)
AI-generated conceptual illustration by BIG CHANGE.

An AI coding agent can add Supabase email sign-in to an existing React app, but the useful deliverable is a checked change to the app's real data boundary. This guide gives the agent a complete client and Row Level Security reference, then asks the app owner to inspect the actual repository, migration and allowed and denied requests before release. It covers a client-rendered React/Vite app whose owner has chosen Supabase Auth and Postgres for this task.

## The big change

- **What changed:** The coding assignment includes confirmation, sign-in, persistent sessions, recovery, sign-out and owner-scoped data operations in one reviewable change.
- **Why it matters:** A signed-in screen can still expose another user's rows if the database or an existing server route trusts browser state.
- **What to watch:** The code below is a documented reference. The owner's own callbacks, email delivery, migrations and access checks determine whether the actual app is ready.

The owner needs access to the existing React/Vite source and data, authority to configure the chosen Supabase project, and a file-capable AI coding agent allowed to inspect that repository. A Next.js or server-rendered app, a different database, or organization roles needs a separate integration. For a new app built inside Google AI Studio, see our [AI Studio and Cloud Run guide](https://bigchange.ai/blog/google-ai-studio-build-cloud-run-web-app-guide). Our [Replit feature board guide](https://bigchange.ai/blog/replit-agent-feature-feedback-board-guide) uses its own managed Clerk setup; this guide addresses an existing app's Supabase identity and data rules.

Authentication establishes which person has a session. Authorization decides which rows and operations that person may reach. A React route guard changes the screen; Supabase Postgres Row Level Security (RLS) enforces the selected route's database boundary. If the app also has its own API, protect every relevant server endpoint independently with a validated user token and an owner/role decision; a browser flag or client-supplied user ID is not proof. For sensitive or regulated data, obtain an appropriate application-specific security review before release.

## Step 1: Inventory the real app and agree on the boundary

In an authorized, clean working tree, ask the agent to read the existing package and lockfile, Vite environment configuration, React entry/routing, every database call, all server endpoints, and the current deployment target. Have it list which pages, rows and mutations guests may access; which require the signed-in owner; and whether any current server uses a privileged Supabase key. Decide account invitation/registration policy and whether each already-created user needs migration. Preserve real user records. The owner reviews the proposed data and access map before the agent edits code or SQL. If the app is not actually this Vite/Supabase route, stop this recipe and use the matching first-party guidance.

Use an existing authorized AI coding agent able to read and edit local files. Give it only repositories, docs and test data that the owner may send to its provider. Keep actual keys, real customer records and production email links out of prompts and logs. Review every proposed diff; an agent's success message is not a security test. Use the provider account's actual permission settings and verify its current quota or charges before a model run. No paid agent execution is needed to read this guide.

Before implementing it, check the current [Supabase billing and included usage](https://supabase.com/docs/guides/platform/billing-on-supabase) for this project, including Auth, database, storage and data transfer, plus the app host's own plan. Confirmation and recovery email depend on the [configured SMTP service and its delivery limits](https://supabase.com/docs/guides/auth/auth-smtp); check that provider's terms and charges too. The coding agent may consume its provider's quota or incur usage charges. These costs and limits depend on the reader's actual accounts; BIG CHANGE measured no bill.

## Step 2: Configure Supabase for the app's two URLs

In the **reader's** Supabase project, enable Email authentication and email confirmation. Supabase says hosted projects normally require confirmation, but check the actual setting. The reference route deliberately allows account creation but **requires separate owner-controlled enrollment before protected data access**. An owner must identify and enroll real users through an approved privileged administration process; the browser cannot add membership. If the app instead serves every verified registrant, review and change that policy deliberately.

Set the Auth Site URL to the real production origin. Allow the exact local and production versions of **both** `/auth/callback` (confirmation) and `/account/update-password` (recovery). For a Vite development app, the local origin may be `http://localhost:5173`; confirm its actual port. Use HTTPS for production. Both paths must return the client SPA through the host's fallback/rewrite. Check the confirmation and recovery email templates against those redirects. Supabase documents the built-in email service as best-effort and restricted to pre-authorized team addresses. Configure an authorized custom SMTP service for delivery to other test users or a production audience, then check its actual limits. Record actual delivery to a permitted test account; API success alone does not establish delivery.

From the existing app directory, use its existing package manager to install the current `@supabase/supabase-js` **v2** package and commit the corresponding lockfile. For an npm-managed app, the command is `npm install @supabase/supabase-js@^2`; use the equivalent add command if the existing lockfile belongs to another manager. Record the resolved version from that lockfile, then run the app's existing build and relevant test scripts, such as `npm run build` only when its package scripts define it. Do not overwrite the package or lockfile with a sample app. The official React quickstart uses the project URL and a **publishable** key through `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY` in `.env.local`. Those `VITE_` values are exposed in browser output. A publishable key identifies the app component; it is not a user credential or a substitute for RLS. Never place a Supabase secret or legacy `service_role` key, SMTP password or AI provider key in a Vite environment variable, browser bundle, repository or prompt. Restrict any privileged server key to server-only code and recheck that server's own authorization logic.

## Step 3: Start from the explicit client and callback contract

The complete `reference-auth-boundary.jsx` appendix is a React/Vite **reference component**, not a claim that it fits an unknown existing app. It initializes one `@supabase/supabase-js` v2 client with `flowType: 'implicit'`, `persistSession: true`, `autoRefreshToken: true` and `detectSessionInUrl: true`. This is a **client-only** flow: Supabase returns tokens in a URL fragment, detects and stores them in the browser, then emits auth events. It is not the PKCE/SSR path. The reference captures callback error/fragment presence before client initialization, subscribes to `INITIAL_SESSION`/`PASSWORD_RECOVERY`, renders a loading state while callback processing occurs, removes the fragment after processing, and cleans up its event subscription. Only a processed `PASSWORD_RECOVERY` event with a session enables the recovery form; an ordinary stored `INITIAL_SESSION` does not. If that event never arrives, the reference keeps the form unavailable. Do not substitute the React quickstart's magic-link `verifyOtp` example for this password flow. A server-rendered app must follow the documented PKCE path instead.

Copy both complete files from the appendices into one reviewed client source directory, for example `src/auth/reference-auth-boundary.jsx` and adjacent `src/auth/reference-auth-view.mjs`, so the relative import resolves. Give the files app-specific names only if the import is updated too. Mount `<AuthBoundary><YourExistingApp /></AuthBoundary>` at its existing React root; import the named `supabase` client into existing data modules instead of creating competing browser clients. Its `/auth/callback` and `/account/update-password` paths must resolve to that same root component. It checks the server-controlled membership table and shows an access-pending state for signed-in users without enrollment; the SQL still enforces that boundary. Adapt its layout and real navigation only after preserving the auth states and tested callbacks. Initial callback metadata stays separate from current view state, so a processed confirmation and successful reset can return to the ordinary app or sign-in screen; invalid direct callback paths retain their unavailable state. The example intentionally leaves the existing app's data code intact for an owner-reviewed RLS/API migration. A successful screen render does not complete that migration.

The example calls `signUp({ email, password, options: { emailRedirectTo } })`, `signInWithPassword`, `resetPasswordForEmail(email, { redirectTo })`, `updateUser({ password })` and `signOut({ scope: 'local' })`. It assumes email confirmation remains enabled and flags an unexpectedly returned signup session for settings review. The update page is shown only after this browser receives `PASSWORD_RECOVERY` with a session on the reset path. **That recovery flag is an application UI condition.** Supabase documents `updateUser` for authenticated users generally, so the provider does not enforce a recovery-only password update based on this flag. An ordinary signed-in password-change feature would need its own reviewed UI and policy. A direct visit, including one with a forged `#type=recovery` and an already stored ordinary session, shows a request-new-link state unless the recovery event occurs. Invalid or expired links do not display a success claim.

## Step 4: Ask the agent to integrate the complete change

Copy the prompt below into the approved file-capable agent session after filling the bracketed facts from the real app. It is an implementation brief, not a report that the requested work happened.

```prompt
Work only in my authorized existing React/Vite repository at [path], against my [Supabase project/environment] and its existing data model. First inspect package/lockfile, React routes, data calls, server endpoints, migrations, tests and deployment configuration. Show a proposed file/data-access map and ask me to resolve unknown ownership or role rules before editing. Use the current Supabase React, password auth, redirect, sessions, API-key and RLS documentation linked in this guide. Record the installed supabase-js v2 version. Do not invent a test result, account, customer record or role.

Integrate the full accompanying reference-auth-boundary.jsx and its reference-auth-view.mjs import into this actual app, preserving its implicit client-only callback/session/recovery contract. Adapt the existing root/routing and auth screens, but do not silently swap in a magic-link, PKCE or server-rendered recipe. Keep exact confirmation and recovery redirect paths and SPA host fallback. A direct visit to the update page or an ordinary persisted session with a spoofed recovery fragment must not enable the form; require the processed PASSWORD_RECOVERY event and fail closed if it is missed. Do not assert that the provider limits updateUser to recovery sessions; it supports ordinary authenticated password changes too. Do not log credentials, link tokens, access tokens, refresh tokens or personal data.

Use reference-restricted-notes.sql as an operation-specific pattern, not as a blind migration. For each protected table and operation in the actual app, review old grants and every existing policy, enable RLS, restrict anon, grant only intended operations to authenticated, and write distinct SELECT, INSERT WITH CHECK, UPDATE USING plus WITH CHECK, and DELETE USING rules. SELECT is also needed for expected UPDATE readback. Derive ownership from verified user identity, not untrusted form fields; never let a user reassign owner_id. This route keeps a server-controlled enrollment row, including an authenticated-but-unenrolled deny case. If existing data lacks correct owner IDs, stop for a real ownership/migration decision. If the app has custom server APIs, validate the caller through Supabase Auth on the server and enforce resource-level authorization on every protected read/mutation. Never let a privileged secret-key route bypass those checks.

Show the exact diff, migration and configuration names, plus the app's actual build/test output. Give me a table of allowed and denied checks for signed-out, user A, user B and recovery state. Keep external writes, SQL application, account changes and release separate for my review. Do not claim an access test passed until you show the actual request, identity, observed response and environment. Do not publish or deploy.
```

The client file and SQL file are complete, copyable **reference artifacts** for this selected route. The SQL creates a new `private_notes` table to make the four operation policies reviewable; it must never be applied over an unknown existing schema. The agent must adapt those policies to every real protected table and return a precise diff for human review. If the actual app has additional storage, RPCs or server APIs, this reference alone does not protect them.

## Step 5: Inspect the server rule, then test both sides

Before applying a migration, read its SQL and compare every exposed table and view to the access map. The complete `reference-restricted-notes.sql` appendix shows explicit public-schema usage, anonymous denial, authenticated grants, separate SELECT/INSERT/UPDATE/DELETE policies, and a membership table that users can read only for themselves but cannot enroll themselves through browser grants. A signed-in but unenrolled user receives no notes. The owner adds enrollment rows only through a separate privileged procedure using verified user IDs. Existing permissive policies, grants, views and functions may widen access, so audit them before adapting this example. Check any storage buckets, RPCs and custom API routes separately. A hidden button or route guard is not authorization.

The complete `reference-rls-checks.mjs` appendix and its imported `reference-denial-classifier.mjs` appendix form a direct-request checker for the **new reference tables in an owner-approved staging project only**. It requires three distinct confirmed test accounts: A and B enrolled, C deliberately unenrolled. Put both files together in the existing app at `scripts/auth-staging/reference-rls-checks.mjs` and adjacent `scripts/auth-staging/reference-denial-classifier.mjs`. Run from that app root so Node resolves the installed, lockfile-pinned supabase-js v2 package and the relative helper import. Supply `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `TEST_A_EMAIL`, `TEST_A_PASSWORD`, `TEST_B_EMAIL`, `TEST_B_PASSWORD`, `TEST_C_EMAIL` and `TEST_C_PASSWORD` as local process environment variables, with credentials excluded from source control and model prompts. The checker is an ES module; after approved staging setup, run `node scripts/auth-staging/reference-rls-checks.mjs` from the app root. It creates and removes fixture rows, so inspect any leftover rows after a failed run before a retry. It signs in test identities, attempts guest and cross-user operations including forged owner and reassignment, then reads the owner rows back. It deliberately records only pass labels, not passwords or tokens. The classifier accepts a documented `42501` permission response with the expected guest/authenticated HTTP status, or a successful `200` empty-row result for a row-filtered operation; a transport error, missing response, `5xx`, or unrelated error fails the check. A zero-row result is paired with a known owner row and subsequent owner readback. If a check fails, inspect the staging rows it may have left before rerunning; do not blindly replay against production or infer that the reader's actual app tables passed. Adapt the same checks to each real protected route and table.

On an approved development or staging environment, use the three distinct confirmed **test** identities named above: A and B enrolled with different owned rows, and C deliberately unenrolled. Add a separate unconfirmed test identity for the confirmation-policy check. Record the actual build, auth setting, callback URL, installed package version and test timestamp. Check these observed outcomes before accepting the change:

1. A new address receives a confirmation message, the allow-listed link returns to the app, and sign-in succeeds only under the configured confirmation policy. A wrong password and an unconfirmed account do not expose protected data.
2. Reload and reopen the app to check the retained session and intended protected view. Supabase's browser client normally persists and refreshes the session; verify the actual app did so. Do not treat a cached React state value as a verified backend identity.
3. As guest, directly request SELECT, INSERT, UPDATE and DELETE on a known test row and record the response; no protected effect is allowed. Enrolled user A creates a row and reads it back by ID, then updates it and reads the changed body, then deletes it and verifies absence. Record actual returned rows or a fresh readback, not only a no-error response.
4. Enrolled user B directly selects A's known ID, attempts UPDATE and DELETE on it, attempts INSERT with `owner_id` forged to A's user ID, and attempts to change B's own row's `owner_id` to A. A then reads back its original row and B reads back B's row to prove the denied attempts caused no cross-user change. An RLS-filtered SELECT/UPDATE/DELETE can validly return **zero rows with no error**; that is an expected denial only when the target's existence and unchanged state are independently confirmed by its owner. A failed request, missing fixture or unreadable response is unresolved, not a pass.
5. A confirmed but **unenrolled** identity cannot read or mutate any protected note; an unconfirmed identity cannot sign in under the configured confirmation setting. Verify both separately. The Auth account alone does not make someone a member of the app.
6. A reset request gives a neutral response for existing and absent addresses. A valid recovery email returns to the allow-listed update-password page, establishes a session and offers a password update. A direct page visit and an expired/reused link show recovery unavailable. `updateUser({ password })` requires an authenticated session, but the provider also permits normal authenticated password updates; this UI intentionally gates its recovery form. Confirm the new password from a fresh sign-in and review existing-session behavior against project policy.
7. Local sign-out clears this browser's session and protected UI. A previously issued access JWT may remain valid until its expiry; if the app needs immediate revocation for high-risk operations, design and test a separate server-side session check. Do not promise instant global invalidation merely because the UI cleared.

If an email does not arrive, inspect the project's actual provider/SMTP settings, quota and message logs before resending. If callbacks land on the wrong host, compare the exact Site URL, allow list, `emailRedirectTo`/`redirectTo`, host rewrite and email template. If another user's rows appear, hold the change, inspect table grants and every policy and server route, then rerun the denied request after the smallest reviewed fix. Distinguish an **expected zero-row RLS denial with owner readback** from an empty fixture, failed request or missing response. Record fresh readback after each repair. Release approval, production keys and production SQL are separate owner decisions.

## Sources & further reading

- [Supabase React quickstart](https://supabase.com/docs/guides/auth/quickstarts/react): current Vite client and publishable-key setup.
- [Password-based Auth](https://supabase.com/docs/guides/auth/passwords): sign-up, confirmation, sign-in, recovery and `updateUser`; its examples are documentation, not tests performed here.
- [Redirect URLs](https://supabase.com/docs/guides/auth/redirect-urls): Site URL and allowed callbacks.
- [User sessions](https://supabase.com/docs/guides/auth/sessions) and [signing out](https://supabase.com/docs/guides/auth/signout): refresh and JWT lifetime boundary.
- [API keys](https://supabase.com/docs/guides/getting-started/api-keys) and [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security): publishable versus secret keys, grants and per-row policy checks.
- [Auth state events](https://supabase.com/docs/reference/javascript/auth-onauthstatechange): initial session, password recovery and sign-out event names.
- [Implicit flow](https://supabase.com/docs/guides/auth/sessions/implicit-flow): the selected client-only fragment and session-establishment path.
- [Database API `42501` errors](https://supabase.com/docs/guides/troubleshooting/database-api-42501-errors): permission error and guest/authenticated response status mapping used by the optional checker.
- [Supabase billing](https://supabase.com/docs/guides/platform/billing-on-supabase): plan, compute and usage items to check in the reader's project.
- [Custom SMTP](https://supabase.com/docs/guides/auth/auth-smtp): email delivery setup, default-service restrictions and provider-dependent limits.

These first-party documents were checked on 22 October 2026. BIG CHANGE also reviewed the five reference files and local synthetic checks. No reader app, Supabase account, actual delivery, callback, database policy or production access result was observed. The owner must accept or hold the exact changed revision on the basis of its own staging evidence.

## Complete reference appendices

These are the full current public files for the selected client-only implicit-flow route. They are starting material for an authorized agent, not a migration for an unknown existing schema. Keep each named import beside its parent file. The JSX belongs in the existing app's reviewed client source; the SQL is a new-table staging pattern; the Node checker belongs in a separate staging test directory.

### reference-auth-boundary.jsx

```javascript
// Public reference for a client-rendered React/Vite app using supabase-js v2.
// Mount <AuthBoundary><YourExistingApp /></AuthBoundary> at the existing root.
// The existing app's data paths still need the reviewed RLS/server rules.
import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'
import { authView, recoveryFromAuthEvent } from './reference-auth-view.mjs'

const projectUrl = import.meta.env.VITE_SUPABASE_URL
const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
if (!projectUrl || !publishableKey) {
  throw new Error('Set VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY')
}

const entryPath = window.location.pathname
const entryFragment = new URLSearchParams(window.location.hash.slice(1))
const entryHasAuthFragment = entryFragment.has('access_token') || entryFragment.has('error') || entryFragment.has('error_code')
const entryError = entryFragment.has('error') || entryFragment.has('error_code')

export const supabase = createClient(projectUrl, publishableKey, {
  auth: {
    flowType: 'implicit',
    persistSession: true,
    autoRefreshToken: true,
    detectSessionInUrl: true,
  },
})

export function AuthBoundary({ children }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname)
  const [session, setSession] = useState(null)
  const [loading, setLoading] = useState(true)
  const [membership, setMembership] = useState('loading')
  const [recovery, setRecovery] = useState(false)
  const [mode, setMode] = useState('signin')
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [newPassword, setNewPassword] = useState('')
  const [busy, setBusy] = useState(false)
  const [notice, setNotice] = useState('')

  useEffect(() => {
    // Subscribe synchronously. Supabase initializes from the implicit URL
    // fragment, persists tokens and emits INITIAL_SESSION/PASSWORD_RECOVERY.
    const { data: { subscription } } = supabase.auth.onAuthStateChange((event, nextSession) => {
      setSession(nextSession)
      setRecovery(previous => recoveryFromAuthEvent({
        previous, event, session: nextSession, path: entryPath, entryError,
      }))
      if (event === 'INITIAL_SESSION') {
        setLoading(false)
      }
      if (event === 'SIGNED_OUT' || event === 'SIGNED_IN' || event === 'PASSWORD_RECOVERY') {
        setLoading(false)
      }
      if (entryHasAuthFragment && (event === 'INITIAL_SESSION' || event === 'PASSWORD_RECOVERY')) {
        // Clear any token-bearing fragment after the client has processed it.
        window.history.replaceState({}, '', entryPath + window.location.search)
      }
      if (entryPath === '/auth/callback' && !entryError && entryHasAuthFragment && nextSession &&
          (event === 'INITIAL_SESSION' || event === 'SIGNED_IN')) {
        window.history.replaceState({}, '', '/')
        setCurrentPath('/')
      }
    })
    return () => subscription.unsubscribe()
  }, [])

  useEffect(() => {
    if (!session?.user?.id) {
      setMembership('loading')
      return
    }
    let current = true
    setMembership('loading')
    supabase.from('app_memberships').select('user_id')
      .eq('user_id', session.user.id).maybeSingle()
      .then(({ data, error }) => {
        if (current) setMembership(error ? 'error' : data ? 'member' : 'none')
      })
    return () => { current = false }
  }, [session?.user?.id])

  async function submit(event) {
    event.preventDefault()
    setBusy(true)
    setNotice('')
    try {
      if (mode === 'signup') {
        const { data, error } = await supabase.auth.signUp({
          email,
          password,
          options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
        })
        if (error) throw error
        if (data.session) {
          // This reference requires email confirmation. Check the project setting.
          await supabase.auth.signOut({ scope: 'local' })
          setNotice('Check the project email-confirmation setting before continuing.')
        } else {
          setNotice('If this address can register, check its email for a confirmation link.')
        }
      } else if (mode === 'forgot') {
        const { error } = await supabase.auth.resetPasswordForEmail(email, {
          redirectTo: `${window.location.origin}/account/update-password`,
        })
        if (error) throw error
        setNotice('If the address is registered, check its email for a reset link.')
      } else {
        const { error } = await supabase.auth.signInWithPassword({ email, password })
        if (error) throw error
      }
    } catch {
      setNotice('The request could not be completed. Check the details and try again.')
    } finally {
      setBusy(false)
      setPassword('')
    }
  }

  async function changePassword(event) {
    event.preventDefault()
    if (!recovery || !session) return
    setBusy(true)
    setNotice('')
    const { error } = await supabase.auth.updateUser({ password: newPassword })
    setNewPassword('')
    if (error) {
      setNotice('The password could not be changed. Request a fresh reset link.')
    } else {
      // Recovery is an application UI state. Supabase allows updateUser for
      // ordinary authenticated sessions too; this is not provider enforcement.
      const { error: signOutError } = await supabase.auth.signOut({ scope: 'local' })
      if (signOutError) {
        setNotice('Password changed. Sign out in this browser before continuing.')
      } else {
        setRecovery(false)
        window.history.replaceState({}, '', '/')
        setCurrentPath('/')
        setMode('signin')
        setNotice('Password changed. Sign in with the new password.')
      }
    }
    setBusy(false)
  }

  async function signOut() {
    setBusy(true)
    const { error } = await supabase.auth.signOut({ scope: 'local' })
    if (error) setNotice('Sign-out failed. Check the current session before leaving.')
    setBusy(false)
  }

  const view = authView({ loading, path: currentPath, recovery, session, entryError, entryHasAuthFragment })
  if (view === 'loading') return <p role="status">Checking your session…</p>

  if (view === 'reset-unavailable') {
    return <main><h1>Reset link unavailable</h1><p>The link is missing, expired or invalid. Request a new one.</p><button onClick={() => { window.location.assign('/') }}>Return to sign in</button></main>
  }
  if (view === 'change-password') {
    return <main><h1>Choose a new password</h1><form onSubmit={changePassword}><label>New password <input type="password" autoComplete="new-password" minLength={8} required value={newPassword} onChange={e => setNewPassword(e.target.value)} /></label><button disabled={busy}>Change password</button></form><p role="status">{notice}</p></main>
  }

  if (view === 'confirmation-unavailable') {
    return <main><h1>Confirmation link unavailable</h1><p>Check the link or request a new confirmation email.</p><button onClick={() => { window.location.assign('/') }}>Return to sign in</button></main>
  }
  if (view === 'app') {
    if (membership === 'loading') return <p role="status">Checking app access…</p>
    if (membership === 'error') return <main><h1>Access check unavailable</h1><p>Try again later or contact the app owner.</p><button onClick={signOut} disabled={busy}>Sign out</button></main>
    if (membership === 'none') return <main><h1>Access pending</h1><p>Your sign-in is confirmed, but the app owner has not enrolled this account.</p><button onClick={signOut} disabled={busy}>Sign out</button></main>
    return <><button type="button" onClick={signOut} disabled={busy}>Sign out</button><p role="status">{notice}</p>{children}</>
  }

  return <main><h1>{mode === 'signup' ? 'Create account' : mode === 'forgot' ? 'Reset password' : 'Sign in'}</h1><form onSubmit={submit}><label>Email <input type="email" autoComplete="email" required value={email} onChange={e => setEmail(e.target.value)} /></label>{mode !== 'forgot' && <label>Password <input type="password" autoComplete={mode === 'signup' ? 'new-password' : 'current-password'} minLength={8} required value={password} onChange={e => setPassword(e.target.value)} /></label>}<button disabled={busy}>{mode === 'signup' ? 'Create account' : mode === 'forgot' ? 'Send reset link' : 'Sign in'}</button></form><p role="status">{notice}</p><button type="button" onClick={() => { setMode('signin'); setNotice('') }}>Sign in</button><button type="button" onClick={() => { setMode('signup'); setNotice('') }}>Create account</button><button type="button" onClick={() => { setMode('forgot'); setNotice('') }}>Forgot password?</button></main>
}
```

### reference-auth-view.mjs

```javascript
// Pure view decision shared by the reference UI and its local regression checks.
export function recoveryFromAuthEvent({ previous, event, session, path, entryError }) {
  if (!session || entryError || path !== '/account/update-password' ||
      event === 'SIGNED_OUT' || event === 'SIGNED_IN') return false
  // A stored ordinary session and a URL fragment's type parameter never open
  // the recovery form. Only the SDK's processed recovery event can do so.
  return event === 'PASSWORD_RECOVERY' ? true : previous
}

export function authView({ loading, path, recovery, session, entryError, entryHasAuthFragment }) {
  if (loading) return 'loading'
  if (path === '/account/update-password') {
    return recovery && session && !entryError ? 'change-password' : 'reset-unavailable'
  }
  if (path === '/auth/callback' && (entryError || !entryHasAuthFragment || !session)) {
    return 'confirmation-unavailable'
  }
  return session ? 'app' : 'sign-in'
}
```

### reference-restricted-notes.sql

```sql
-- Public reference for a NEW, EMPTY Supabase project, not a migration to run
-- against the reader's existing database. The reader's agent must inventory
-- every exposed table/view/RPC/storage/API and adapt a reviewed migration.
-- Registration creates an Auth identity, but data access requires a separate
-- owner-controlled app_memberships row. No client can insert membership.

create table public.app_memberships (
  user_id uuid primary key references auth.users(id) on delete cascade
);

create table public.private_notes (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null default auth.uid() references auth.users(id) on delete cascade,
  body text not null
);

alter table public.app_memberships enable row level security;
alter table public.private_notes enable row level security;

-- An exposed public schema requires schema usage, table grants AND RLS.
-- Existing policies or grants must be audited before adapting this example:
-- permissive policies can combine and widen access.
grant usage on schema public to authenticated;
revoke all on table public.app_memberships from public, anon, authenticated;
revoke all on table public.private_notes from public, anon, authenticated;
grant select on table public.app_memberships to authenticated;
grant select, insert, update, delete on table public.private_notes to authenticated;

create policy "member reads own membership"
on public.app_memberships for select to authenticated
using ((select auth.uid()) = user_id);

create policy "enrolled owner reads notes"
on public.private_notes for select to authenticated
using (
  (select auth.uid()) = owner_id
  and exists (
    select 1 from public.app_memberships m
    where m.user_id = (select auth.uid())
  )
);

create policy "enrolled owner inserts notes"
on public.private_notes for insert to authenticated
with check (
  (select auth.uid()) = owner_id
  and exists (
    select 1 from public.app_memberships m
    where m.user_id = (select auth.uid())
  )
);

create policy "enrolled owner updates notes"
on public.private_notes for update to authenticated
using (
  (select auth.uid()) = owner_id
  and exists (
    select 1 from public.app_memberships m
    where m.user_id = (select auth.uid())
  )
)
with check (
  (select auth.uid()) = owner_id
  and exists (
    select 1 from public.app_memberships m
    where m.user_id = (select auth.uid())
  )
);

create policy "enrolled owner deletes notes"
on public.private_notes for delete to authenticated
using (
  (select auth.uid()) = owner_id
  and exists (
    select 1 from public.app_memberships m
    where m.user_id = (select auth.uid())
  )
);

-- Enrollment is an owner's separate, privileged administrative operation
-- using verified Auth user IDs and an approved process. This file deliberately
-- gives no browser role INSERT/UPDATE/DELETE rights on app_memberships.
```

### reference-rls-checks.mjs

```javascript
// Run only against an owner-approved EMPTY/STAGING copy of the reference SQL.
// Required local environment: SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY,
// TEST_A_EMAIL/PASSWORD, TEST_B_EMAIL/PASSWORD, TEST_C_EMAIL/PASSWORD.
// A and B must be confirmed and enrolled by the owner. C is confirmed but
// deliberately unenrolled. Do not commit credentials or use customer accounts.
import { randomUUID } from 'node:crypto'
import { createClient } from '@supabase/supabase-js'
import { isExpectedDenial } from './reference-denial-classifier.mjs'

const names = [
  'SUPABASE_URL', 'SUPABASE_PUBLISHABLE_KEY',
  'TEST_A_EMAIL', 'TEST_A_PASSWORD',
  'TEST_B_EMAIL', 'TEST_B_PASSWORD',
  'TEST_C_EMAIL', 'TEST_C_PASSWORD',
]
for (const name of names) if (!process.env[name]) throw new Error(`Missing ${name}`)

function client() {
  return createClient(process.env.SUPABASE_URL, process.env.SUPABASE_PUBLISHABLE_KEY, {
    auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
  })
}
const guest = client()
const a = client()
const b = client()
const c = client()

async function login(api, email, password) {
  const { data, error } = await api.auth.signInWithPassword({ email, password })
  if (error || !data.user?.id) throw new Error('A test identity could not sign in')
  return data.user.id
}
function expect(condition, label) {
  if (!condition) throw new Error(`FAIL: ${label}`)
  console.log(`PASS: ${label}`)
}
async function ownRow(api, id) {
  const result = await api.from('private_notes').select('id,owner_id,body').eq('id', id).single()
  if (result.error) throw new Error('Owner readback failed')
  return result.data
}

const aId = await login(a, process.env.TEST_A_EMAIL, process.env.TEST_A_PASSWORD)
const bId = await login(b, process.env.TEST_B_EMAIL, process.env.TEST_B_PASSWORD)
const cId = await login(c, process.env.TEST_C_EMAIL, process.env.TEST_C_PASSWORD)
expect(new Set([aId, bId, cId]).size === 3, 'distinct test identities')

const marker = `auth-check-${randomUUID()}`
const aCreate = await a.from('private_notes').insert({ body: `${marker}-a` }).select('id,owner_id,body').single()
if (aCreate.error) throw new Error('A insert failed')
const aRow = aCreate.data
expect(aRow.owner_id === aId, 'A insert uses authenticated owner')
const bCreate = await b.from('private_notes').insert({ body: `${marker}-b` }).select('id,owner_id,body').single()
if (bCreate.error) throw new Error('B insert failed')
const bRow = bCreate.data
expect(bRow.owner_id === bId, 'B insert uses authenticated owner')

const guestRead = await guest.from('private_notes').select('id').eq('id', aRow.id)
expect(isExpectedDenial(guestRead, { actor: 'guest', allowFilteredRows: true }), 'guest cannot read A row')
const guestInsert = await guest.from('private_notes').insert({ body: marker }).select('id')
expect(isExpectedDenial(guestInsert, { actor: 'guest' }), 'guest cannot insert')
const guestUpdate = await guest.from('private_notes').update({ body: marker }).eq('id', aRow.id).select('id')
expect(isExpectedDenial(guestUpdate, { actor: 'guest', allowFilteredRows: true }), 'guest cannot update A row')
const guestDelete = await guest.from('private_notes').delete().eq('id', aRow.id).select('id')
expect(isExpectedDenial(guestDelete, { actor: 'guest', allowFilteredRows: true }), 'guest cannot delete A row')
expect((await ownRow(a, aRow.id)).body === `${marker}-a`, 'A row unchanged after guest attempts')

const crossRead = await b.from('private_notes').select('id').eq('id', aRow.id)
expect(isExpectedDenial(crossRead, { actor: 'authenticated', allowFilteredRows: true }), 'B cannot read A row')
const crossUpdate = await b.from('private_notes').update({ body: marker }).eq('id', aRow.id).select('id')
expect(isExpectedDenial(crossUpdate, { actor: 'authenticated', allowFilteredRows: true }), 'B cannot update A row')
const crossDelete = await b.from('private_notes').delete().eq('id', aRow.id).select('id')
expect(isExpectedDenial(crossDelete, { actor: 'authenticated', allowFilteredRows: true }), 'B cannot delete A row')
expect((await ownRow(a, aRow.id)).body === `${marker}-a`, 'A row unchanged after B attempts')

const forgedInsert = await b.from('private_notes').insert({ owner_id: aId, body: marker }).select('id')
expect(isExpectedDenial(forgedInsert, { actor: 'authenticated' }), 'B cannot insert with forged A owner ID')
const reassignment = await b.from('private_notes').update({ owner_id: aId }).eq('id', bRow.id).select('id')
expect(isExpectedDenial(reassignment, { actor: 'authenticated' }), 'B cannot reassign B row to A')
expect((await ownRow(b, bRow.id)).owner_id === bId, 'B row ownership unchanged')

const unenrolledRead = await c.from('private_notes').select('id').eq('id', aRow.id)
expect(isExpectedDenial(unenrolledRead, { actor: 'authenticated', allowFilteredRows: true }), 'confirmed but unenrolled C cannot read')
const unenrolledInsert = await c.from('private_notes').insert({ body: marker }).select('id')
expect(isExpectedDenial(unenrolledInsert, { actor: 'authenticated' }), 'confirmed but unenrolled C cannot insert')

const ownUpdate = await a.from('private_notes').update({ body: `${marker}-changed` }).eq('id', aRow.id).select('id,owner_id,body').single()
if (ownUpdate.error) throw new Error('A own update failed')
expect(ownUpdate.data.body === `${marker}-changed`, 'A can update own row')
expect((await ownRow(a, aRow.id)).body === `${marker}-changed`, 'A update persists')
const ownDelete = await a.from('private_notes').delete().eq('id', aRow.id).select('id')
if (ownDelete.error) throw new Error('A own delete failed')
expect(ownDelete.data?.length === 1, 'A can delete own row')
const afterDelete = await a.from('private_notes').select('id').eq('id', aRow.id)
expect(!afterDelete.error && afterDelete.data.length === 0, 'A row absent after own delete')

const cleanup = await b.from('private_notes').delete().eq('id', bRow.id).select('id')
expect(!cleanup.error && cleanup.data?.length === 1, 'B test row removed')
console.log('Reference RLS checks completed for the staging fixture; review actual app tables and routes separately.')
```

### reference-denial-classifier.mjs

```javascript
// Pure classifier for the direct Supabase Data API checks in this packet.
// It recognizes the documented Postgres insufficient-privilege SQLSTATE and
// successful zero-row RLS filtering; it cannot prove a target row existed.
export function isExpectedDenial(result, { actor, allowFilteredRows = false }) {
  if (!result || typeof result !== 'object') return false
  if (actor !== 'guest' && actor !== 'authenticated') return false
  const expectedErrorStatus = actor === 'guest' ? 401 : 403

  if (result.error != null) {
    return result.error.code === '42501' && result.status === expectedErrorStatus
  }

  // Every check in the companion script uses .select(), so a filtered
  // SELECT/UPDATE/DELETE has a successful 200 response and [] data.
  return allowFilteredRows === true && result.status === 200 &&
    Array.isArray(result.data) && result.data.length === 0
}
```

## Sources

- [Supabase React quickstart](https://supabase.com/docs/guides/auth/quickstarts/react) — React/Vite client setup; its example uses magic links, so the article's password flow uses separate password documentation.
- [Password-based Auth](https://supabase.com/docs/guides/auth/passwords) — Signup, confirmation, sign-in, reset and authenticated password update.
- [Redirect URLs](https://supabase.com/docs/guides/auth/redirect-urls) — Site URL and allow list for local and production callbacks.
- [Auth state change events](https://supabase.com/docs/reference/javascript/auth-onauthstatechange) — INITIAL_SESSION, PASSWORD_RECOVERY and SIGNED_OUT handling.
- [Implicit flow](https://supabase.com/docs/guides/auth/sessions/implicit-flow) — Selected browser client fragment and session path.
- [Sessions](https://supabase.com/docs/guides/auth/sessions) — Access and refresh token behavior.
- [Signing out](https://supabase.com/docs/guides/auth/signout) — Local scope and surviving access JWT boundary.
- [API keys](https://supabase.com/docs/guides/getting-started/api-keys) — Public browser key versus secret server key.
- [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) — Grants and operation-specific row policies.
- [Database API 42501 errors](https://supabase.com/docs/guides/troubleshooting/database-api-42501-errors) — Permission-code and HTTP-status conditions in the reference checker.
- [Supabase billing](https://supabase.com/docs/guides/platform/billing-on-supabase) — Plan and variable usage categories; actual project bill and quota remain reader-specific.
- [Custom SMTP](https://supabase.com/docs/guides/auth/auth-smtp) — Default-service delivery restrictions, SMTP setup and provider-dependent email limits.