Skip to content

Auth

Next.js boilerplate with Supabase Auth

Postgres-native auth where the database, not the API layer, is the last line of defence.

Cookie-based Supabase Auth for the Next.js App Router: sign-in with Google, GitHub and Microsoft buttons for whichever providers your Supabase project has switched on, email and password, magic links, password reset, and profile and security settings. It ships @supabase/ssr clients, session refresh in the proxy and RLS-aware query helpers, and the shared getSessionUser, requireUser and requireRole surface is backed by app_metadata.role.

What Supabase Auth adds to the agent layer: 4 rules · 3 skills · 7 solution docs

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Supabase Auth?

Pick it if

Teams already on Supabase Postgres who want row-level security as the authorization model. With the policies right, a buggy query cannot return another tenant's rows.

Watch out for

  • Authorization lives in SQL policies, not in TypeScript. That is the whole point, and it is also the learning curve: you debug permissions with explain and set role, not a debugger.
  • Auth is coupled to the Supabase project. Moving the database off Supabase means moving users, JWT signing and every policy at the same time.
Show 3 more
  • Cookie-based sessions in the App Router need a proxy refresh. Skip it and users get logged out after an hour with no error anywhere.
  • Roles live in app_metadata, which only the service-role key can write. Good for security, awkward for self-service role changes.
  • The service-role key bypasses every policy. One import of it into a client component and the whole database is public.

What it costs

Free: 50,000 monthly active users. Pro: 100,000 included, then $0.00325 per MAU. Anonymous sign-ins are included. SAML SSO needs Pro: 50 SSO users included, then $0.015 each.

Prices change. Check with Supabase Auth before you commit.

registry/tested.yaml

Tested with Supabase Auth

Each pair was installed, typechecked, linted, built and booted together.

Database
Supabase
Admin panel
Admin panel
Error tracking
Sentry
Customer support
Crisp

Not tested yet: Neon.

What it adds

What Supabase Auth adds to the repo

Read straight from the supabase-auth manifest, so it is exactly what lands in your repo.

Environment variables

No environment variables. Nothing to sign up for, nothing to paste.

Dependencies

  • @supabase/ssr^0.12.7
  • @supabase/supabase-js^2.117.0
  • server-only^0.0.1
  • supabase^2.2.1dev

Scripts

  • bun run auth:policies

    bunx supabase db push

  • bun run auth:seed

    bun scripts/auth/seed.ts

  • bun run auth:types

    bunx supabase gen types typescript --linked --schema public > src/lib/auth/database.types.ts

Files it writes

60 files, at these exact paths.

  • scripts/1 file
    • auth/1 file
      • seed.ts
  • src/52 files
    • app/9 files
      • (supabase-auth)/6 files
        • forgot-password/1 file
          • page.tsx
        • no-access/1 file
          • page.tsx
        • reset-password/1 file
          • page.tsx
        • sign-in/1 file
          • page.tsx
        • sign-up/1 file
          • page.tsx
        • layout.tsx
      • auth/3 files
        • callback/1 file
          • route.ts
        • confirm/1 file
          • route.ts
        • sign-out/1 file
          • route.ts
    • components/19 files
      • auth/19 files
        • settings/6 files
          • connected-accounts.tsx
          • email-card.tsx
          • form-feedback.tsx
          • password-form.tsx
          • profile-form.tsx
          • sessions-card.tsx
        • account-settings.tsx
        • auth-card.tsx
        • check-email.tsx
        • forgot-password-form.tsx
        • header-auth-actions.tsx
        • oauth-buttons.tsx
        • password-input.tsx
        • provider-icons.tsx
        • reset-password-form.tsx
        • sign-in-form.tsx
        • sign-out-button.tsx
        • sign-up-form.tsx
        • supabase-session-listener.tsx
    • lib/24 files
      • auth/24 files
        • actions.ts
        • admin.ts
        • client.ts
        • constants.ts
        • database.types.ts
        • env.ts
        • errors.ts
        • flow.ts
        • form-state.ts
        • identity.test.ts
        • impersonation.ts
        • origin.ts
        • profile.ts
        • provider-settings.test.ts
        • provider-settings.ts
        • providers.ts
        • proxy.test.ts
        • proxy.ts
        • redirect.test.ts
        • redirect.ts
        • rls.ts
        • schemas.ts
        • server.ts
        • session.ts
  • supabase/2 files
    • migrations/2 files
      • 0100_supabase_auth_profiles.sql
      • 0101_supabase_auth_profile_sync.sql
  • tests/3 files
    • e2e/3 files
      • auth.setup.ts
      • auth.spec.ts
      • auth.ts
  • variants/2 files
    • orm-prisma/2 files
      • slots/1 file
        • prisma-external-tables.ts
      • supabase/1 file
        • migrations/1 file
          • 0102_prisma_profiles_without_cross_schema_fk.sql

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot bare-route-groups
  • @slot env-required
  • @slot header-actions
  • @slot legal-processors
  • @slot middleware-matchers
  • @slot providers
  • @slot proxy-handlers
  • @slot verify-checks

The differentiator

What Supabase Auth teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (4)

Loaded when the agent opens a matching file.

Every table needs row-level security and at least one policy

Loads onsupabase/**src/lib/auth/**.claude/rules/every-table-needs-a-policy.md
The default is public

The publishable (anon) key ships in the browser bundle. Anyone who opens devtools has it. The only thing standing between that key and your data is row-level security, and RLS is off by default on a new table.

A table created without it is readable (and, depending on grants, writable) by every visitor to your site. Not "in theory": the REST endpoint is generated automatically for every table in the public schema.

So every migration that creates a table also enables RLS in the same migration:

create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  amount_cents integer not null,
  created_at timestamptz not null default now()
);

alter table public.invoices enable row level security;
alter table public.invoices force row level security;

force makes the policies apply to the table owner too, so a SECURITY DEFINER function cannot accidentally read past them.

RLS on with no policy denies everything

That is safe, and it is almost never finished. A table with RLS enabled and no policy returns zero rows to everyone, which surfaces as an empty list in the UI and an afternoon of debugging a query that is fine.

Write the policies in the same migration:

create policy "invoices: read own"
  on public.invoices for select
  to authenticated
  using ((select auth.uid()) = user_id);

create policy "invoices: admins read all"
  on public.invoices for select
  to authenticated
  using (public.auth_is_admin());
Policy conventions in this repo
  • Name them "<table>: <what>". Policy names show up in error messages and in pg_policies; "invoices: read own" tells you what failed, "policy1" does not.
  • Scope every policy to authenticated unless the data is genuinely public. Without a role, the policy also applies to anon.
  • One policy per operation. for select, for insert, for update, for delete: separately. for all hides the fact that nobody decided what delete should do.
  • using filters what is visible; with check validates what is written. An update policy needs both, or a user can move a row to another owner.
  • Wrap auth.uid() in a subselect ((select auth.uid())) so Postgres evaluates it once per query instead of once per row.
  • Use the helpers. public.auth_role() and public.auth_is_admin() are STABLE, SECURITY DEFINER with a pinned search_path. Reimplementing them inline is how a policy ends up reading a role from a place the user can write.
Insert policies decide ownership
create policy "invoices: insert own"
  on public.invoices for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

Without the with check, a signed-in user can insert a row owned by someone else. That is the most commonly missed policy of the four.

Index what the policy filters

Every policy is a predicate on every query against the table. user_id in the policy means create index on public.invoices (user_id): otherwise each read is a sequential scan, and the slowdown arrives with the data volume, long after the code was written.

Verify, do not assume

bun run verify calls public.auth_rls_status() and fails when any table in the public schema has RLS off. Run it before every deploy. A table added by a migration that forgot the two alter table lines is exactly the kind of thing that passes review and fails in public.

getUser() is the trust boundary, getSession() is not

Loads onsrc/lib/auth/**src/app/**src/components/**.claude/rules/getuser-is-the-trust-boundary.md
The rule

Server-side, identity comes from getUser() in src/lib/auth/session.ts and from nowhere else.

const user = await getUser();          // verified, raw Supabase user
const user = await getSessionUser();   // verified, the shared SessionUser shape
const user = await requireUser();      // verified, or redirected

Never branch on supabase.auth.getSession() on the server. getSession() decodes the auth cookie and returns whatever it contains. The cookie is attacker-writable (it is a value in someone else's browser) so the user object it produces is an unverified claim. getUser() sends the token to the auth server (or verifies its signature) and returns an answer you can act on.

The difference is invisible in development, where the only cookie is one your own sign-in created. It is the whole game in production.

// no: a forged cookie walks straight through this
const { data: { session } } = await supabase.auth.getSession();
if (session?.user.id === ownerId) { /* ... */ }

// yes
const user = await requireUser();
if (user.id === ownerId) { /* ... */ }

The getSession() exported from src/lib/auth/session.ts is not an exception to this: it awaits getUser() first and returns null unless that call vouches for the token. It exists because the shared @/lib/auth/session surface names it, and it is there for the rare caller that needs the access token itself. supabase.auth.getSession() appears in exactly that one function and nowhere else on the server.

Roles come from app_metadata

app_metadata.role is written only by the service-role key, so a user cannot promote themselves. user_metadata is writable by the signed-in user through updateUser: reading a role from it is self-service privilege escalation.

Read roles through roleOf() / getRole() / requireRole() in src/lib/auth/session.ts. Never compare user.user_metadata.role anywhere.

The vocabulary is "user" | "admin": the Role type in that file, the check constraint on profiles.role, and the values set_user_role() accepts, all the same two strings.

user_metadata is read for display only. The name and photo come from display_name and display_avatar_url (what the person set in Settings), else the provider's full_name and avatar_url. src/lib/auth/profile.ts holds that rule, and the 0101 migration holds the same rule in SQL. Showing a name is not a decision, so a self-written value is fine there and nowhere else.

Impersonation is read from app_metadata, bound to the session

SessionUser.impersonatedBy comes from an impersonation marker in app_metadata (src/lib/auth/impersonation.ts), and only counts while the current session's session_id matches the one in the marker. Only the service-role key writes app_metadata, so nobody can mark or unmark their own session. Never read it from user_metadata, a cookie or a query param.

While impersonatedBy is set, every account change is refused: the settings actions in src/lib/auth/actions.ts check it on the server before they touch the user. A new action that changes the account does the same.

A TypeScript role check is not the security boundary

requireRole decides what to render. The policies in supabase/migrations/ decide what the database returns. Both exist, and they are not substitutes:

  • Skip the policy and a missing .eq("user_id", user.id) becomes a data breach.
  • Skip the requireRole and an unauthorised user sees an empty admin shell instead of a redirect, which is confusing but not dangerous.

Write both. Assume the query is wrong and let the policy save you.

The client is display only

createClient() from src/lib/auth/client.ts is for user-initiated actions: sign in, sign out, a realtime subscription. Anything it reads is filtered by RLS as that user, which is what makes it safe to ship.

What it must not do is decide access. A client component that renders an admin panel because user.app_metadata.role === "admin" has decided nothing an attacker cannot decide differently in devtools. The server re-checks, always.

Session refresh belongs in the proxy

updateSession in src/lib/auth/proxy.ts must run before any route renders. Server Components cannot write cookies, so a token refreshed during a render has nowhere to go and the user is signed out an hour later with no error anywhere.

Two things that quietly break it: removing the await supabase.auth.getUser() call inside updateSession because "nothing uses the result" (that call is the refresh) and returning a fresh NextResponse.next() at the end instead of the response the cookies were written to.

The service-role key is a root password

Loads onsrc/lib/auth/**src/app/**supabase/**.claude/rules/service-role-key-is-root.md
What it is

SUPABASE_SERVICE_ROLE_KEY bypasses every row-level security policy in the database. It can read every row of every table for every tenant, and write them too. It is not "the admin key" in the sense of an elevated user: there is no user, there are no policies, there is nothing between it and the data.

Treat every call site the way you would treat sudo in a shell script.

Where it may appear
  • src/lib/auth/env.ts, in supabaseServiceRoleKey(), which refuses to resolve in a browser context.
  • src/lib/auth/admin.ts, in createAdminClient().
  • The Supabase Auth check in scripts/verify.ts, which builds its own service-role client rather than importing admin.ts. That is not a second copy for convenience: admin.ts starts with import "server-only", which is a bare throw in any runtime that does not set the react-server condition, and a script run from a terminal does not set it. The check is the row-level security audit, so it has to run.
  • Nowhere else. Not in a component, not in a page, not in a utility, not in a test fixture that gets bundled.

Server-only modules start with import "server-only" so an accidental import from a client component fails the build instead of shipping the key to every visitor. The flip side is the one above: a terminal script cannot import them at all, so anything bun run verify needs is either constructed inline or run through bun --conditions=react-server.

Where it may be used

Four legitimate reasons, all of which have no user session by nature:

  1. A webhook handler that verified a signature.
  2. A background job or a one-off script.
  3. Writing app_metadata (roles) which users must not be able to write.
  4. A deliberate cross-tenant read, such as an internal metrics job.

Not legitimate, however convenient at 2am:

  • "The RLS policy is blocking me, I will use the admin client for now."
  • Fetching everything and filtering by tenant in TypeScript.
  • Any code path a signed-out request can reach.

The first one is the dangerous one, because it is usually true that the policy is wrong, and fixing the policy protects every future query, while the bypass protects nothing and quietly becomes permanent.

Use adminQuery, not the raw client

adminQuery(reason, fn) in src/lib/auth/rls.ts requires a written justification next to the call. That is not ceremony: it makes grep -rn "adminQuery(" src/ a complete audit of every RLS bypass in the codebase, which is the first thing anyone reviewing this app's security will want.

Every adminQuery call must still perform its own authorisation. The policies are not enforcing anything here; whatever check you write is the only one.

Never send it anywhere
  • Not to the browser, in any form, including error messages and debug output.
  • Not into a log line, an error report, or an analytics event.
  • Not into a client-side environment variable. A NEXT_PUBLIC_ prefix on this key publishes your entire database.
  • Not into the repository. .env.example gets a placeholder.

If it does leak: rotate it in the Supabase dashboard immediately. Until you do, every row in every table is readable and writable by whoever has it. Then audit what was reachable, because rotation stops the future, not the past.

Keys are per environment

Local, preview and production have different projects and different keys. A production service-role key in a preview environment means a preview bug edits real customer data, and preview environment variables are visible to everyone who can open the hosting dashboard.

Sign-in flows keep return paths safe and reveal nothing about accounts

Loads onsrc/components/auth/**src/lib/auth/**src/app/(supabase-auth)/**src/app/auth/**.claude/rules/sign-in-flows.md
Every return path goes through safeNextPath

?next= arrives from the URL, and the callback routes redirect to it with a fresh session cookie attached. Used raw, /sign-in?next=https://evil.example is an open redirect that fires right after sign-in.

  • Read it with safeNextPath(firstParam(params.next)) from src/lib/auth/redirect.ts. It keeps same-origin paths only, and never /sign-in, /sign-up, /forgot-password or /auth/*.
  • It judges the normalised path as well as the raw string. /..//evil.example starts with one slash and becomes //evil.example (another host) once the URL parser folds the dot segments. Never write a second guard that checks only the input.
  • Build links that carry it with withNext(path, next).
  • Build Supabase redirectTo and emailRedirectTo values with callbackUrl(origin, next) (OAuth, identity linking) and confirmUrl(origin, next) (every emailed link). origin is window.location.origin in the browser or requestOrigin() on the server, never a hardcoded production URL.

No return path means /dashboard (AFTER_SIGN_IN in @/lib/routes).

Provider buttons come from Supabase, not from code or env

getAuthSettings() in src/lib/auth/providers.ts reads the project's public /auth/v1/settings and fails closed: an error shows no OAuth buttons and keeps the email form. Pass its providers down to OAuthButtons. Do not render a button for a provider that is not in that list, and do not add an env flag to switch one on: the Supabase dashboard is the switch. Adding a provider is the add-oauth-provider skill.

Errors never say whether an address has an account
  • Map every Supabase error through authErrorMessage() in src/lib/auth/errors.ts. It matches on error.code, never on message text.
  • "Email or password is incorrect" covers both a wrong password and no such user. The magic link, sign-up and forgot-password forms show the same "check your inbox" panel whether or not the address exists. The one exception is sign-up with "Confirm email" off: signing up signs you in, so a taken address has to be named. Keep confirmation on in production.
  • Callback routes send people back with ?error=<code> from AuthRedirectError, and the page turns the code into fixed copy with redirectErrorMessage(). Never print free text from the URL.
Where each call runs
  • Sign-in, sign-up, magic link and password reset run in the browser (src/components/auth/*-form.tsx). Supabase rate-limits those per IP; a server action would put every visitor behind your server's single IP.
  • OAuth starts in the browser too, so the PKCE verifier cookie is in the browser that comes back to /auth/callback.
  • Account changes (profile, email, password, linked identities, other sessions) are server actions in src/lib/auth/actions.ts. Each one parses with a zod schema from src/lib/auth/schemas.ts, re-reads the session, and refuses while impersonatedBy is set. It returns a FormState; it does not throw for an expected failure.
Sign-out

SignOutButton signs out with scope: "local" (this browser only). "Sign out other devices" in Settings is signOut({ scope: "others" }). Do not switch the menu item to global: signing someone out of their phone from a laptop menu is a surprise nobody asked for.

Skills (3)

Invoked by name.

  • /add-auth-rls-policy

    Add row-level security policies to a Supabase table (owner-scoped, tenant-scoped or admin) with the indexes and the tests that prove they work.

    .claude/skills/add-auth-rls-policy/SKILL.md

  • /add-oauth-provider

    Switch on Google, GitHub or Microsoft sign-in for Supabase Auth, or add a new provider (Apple, Discord, LinkedIn) with its button, icon, redirect URLs and profile mapping.

    .claude/skills/add-oauth-provider/SKILL.md

  • /add-profile-field

    Add a field people edit on /settings/profile (a bio, a time zone, a company) with Supabase Auth, stored in public.profiles behind a column grant, validated with zod and saved by a server action.

    .claude/skills/add-profile-field/SKILL.md

Solution docs (7)

Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.

Show all 7

How it fits

What Supabase Auth needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Build a repo with Supabase Auth

Free and MIT. The builder opens with Supabase Auth picked. You download the zip right away, and we email you the link too.