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
explainandset 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 moreShow fewer
- 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 inpg_policies;"invoices: read own"tells you what failed,"policy1"does not. - Scope every policy
to authenticatedunless the data is genuinely public. Without a role, the policy also applies toanon. - One policy per operation.
for select,for insert,for update,for delete: separately.for allhides the fact that nobody decided what delete should do. usingfilters what is visible;with checkvalidates 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()andpublic.auth_is_admin()areSTABLE,SECURITY DEFINERwith a pinnedsearch_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
requireRoleand 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, insupabaseServiceRoleKey(), which refuses to resolve in a browser context.src/lib/auth/admin.ts, increateAdminClient().- The
Supabase Authcheck inscripts/verify.ts, which builds its own service-role client rather than importingadmin.ts. That is not a second copy for convenience:admin.tsstarts withimport "server-only", which is a barethrowin any runtime that does not set thereact-servercondition, 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:
- A webhook handler that verified a signature.
- A background job or a one-off script.
- Writing
app_metadata(roles) which users must not be able to write. - 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.examplegets 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))fromsrc/lib/auth/redirect.ts. It keeps same-origin paths only, and never/sign-in,/sign-up,/forgot-passwordor/auth/*. - It judges the normalised path as well as the raw string.
/..//evil.examplestarts 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
redirectToandemailRedirectTovalues withcallbackUrl(origin, next)(OAuth, identity linking) andconfirmUrl(origin, next)(every emailed link).originiswindow.location.originin the browser orrequestOrigin()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()insrc/lib/auth/errors.ts. It matches onerror.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>fromAuthRedirectError, and the page turns the code into fixed copy withredirectErrorMessage(). 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 fromsrc/lib/auth/schemas.ts, re-reads the session, and refuses whileimpersonatedByis set. It returns aFormState; 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.
- Logged out after an hour: Supabase cookie refresh in the App RouterAccess tokens expire hourly and Server Components cannot write cookies, so the refresh has to happen in the proxy and be returned on the same response object.docs/solutions/supabase-auth/cookie-refresh-in-the-app-router.md
- getSession() vs getUser(): the Supabase trust trapgetSession() decodes a cookie the browser controls; getUser() verifies it with the auth server. On the server, only one of them is a security check.docs/solutions/supabase-auth/getsession-vs-getuser.md
- Admin impersonation on Supabase Auth, bound to one sessionSupabase Auth has no "view as user". Build it from a server-side magic link plus an app_metadata marker tied to the new session id, so only that session is flagged and nobody can forge it.docs/solutions/supabase-auth/impersonation-bound-to-one-session.md
- Migrating an app that only ever used the anon keyTables with RLS off are public. Turn it on table by table behind a feature switch, write the policies, and fix the queries the policies break, in that order.docs/solutions/supabase-auth/migrating-from-anon-key-only-access.md
- Show only the OAuth buttons your Supabase project has switched onRead the public /auth/v1/settings endpoint on the server, cache it, and fail closed, so a sign-in page never shows a Google button that ends on an error page.docs/solutions/supabase-auth/oauth-buttons-from-auth-settings.md
Show all 7Show fewer
- RLS policy patterns for multi-tenant rowsOwner-scoped, org-scoped and role-scoped policies, the with-check clause people forget, and the indexes that stop a policy from turning every read into a scan.docs/solutions/supabase-auth/rls-patterns-for-multi-tenant-rows.md
- The blast radius of a leaked Supabase service-role keyThe key bypasses every policy for every table. Here is how it leaks, what an attacker gets, how to contain it, and how to make the leak impossible.docs/solutions/supabase-auth/service-role-key-blast-radius.md
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.
Pairs well with
- A storage battery. Suggested, never added for you.
Compared with the alternatives
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.