11 Supabase Auth Lessons From a Year in Production
Supabase

11 Supabase Auth Lessons From a Year in Production

Eleven recurring traps that surface once Supabase Auth is wired into a production Next.js app — drawn from public Supabase docs, the SSR guide, and the postmortems the community has published.

Updated
11 min read
11 Supabase Auth Lessons From a Year in Production

Photo by Kedibone Isaac Makhumisane on Unsplash

Supabase Auth integrates cleanly with Next.js 15 App Router through @supabase/ssr, but the official docs underplay a handful of recurring traps. Below are the eleven that surface most often once a Next.js + Supabase stack reaches real traffic.

This is not a "Supabase bad" post. Supabase Auth is genuinely excellent — it ships primitives for email/password, OAuth, magic links, MFA, and integrates with Postgres RLS. But the integration code is where the bugs hide, not in the auth service itself.

Here is the list.

1. getSession() is not what you think it is#

A common first draft is to call getSession() in middleware to check if a user is logged in. It is fast. It works. Then a security audit — or a careful read of the SSR guide — surfaces the catch: getSession() reads the access token from the cookie and returns whatever is in it. It does not verify the token's signature. A user with a stolen or replayed cookie continues through the app because the middleware never asks Supabase to validate the JWT.

The fix is getUser(), which makes a network call to Supabase to verify the JWT signature and return the canonical user record. Slightly slower (one round-trip), infinitely safer — and explicitly recommended by the SSR docs.

Rule: getSession() is for fast paths where stale or unverified data is acceptable (showing/hiding a "Sign in" button). getUser() is for anywhere a wrong answer has a security consequence.

2. Use auth.users for nothing except the user ID#

auth.users is owned by the Supabase auth schema and is not freely mutable: adding custom columns fails because the schema is managed by Supabase. RLS policies can JOIN against it, but the query plans get ugly and you give up control of the column list.

The pattern that scales:

  • Treat auth.users as opaque
  • Create a public.profiles table with id uuid REFERENCES auth.users(id) as the primary key
  • Add every custom column you want there
  • Use a trigger to create the profile row when a user signs up
sql
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, full_name, avatar_url)
  VALUES (
    NEW.id,
    NEW.raw_user_meta_data->>'full_name',
    NEW.raw_user_meta_data->>'avatar_url'
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
 
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

Now you can JOIN against profiles like any other table, and your RLS policies stay clean.

3. The default refresh token lifetime is 30 days#

Read that again. After 30 days of inactivity, the user is signed out. There is no warning — the session silently expires, and the next request that hits getUser() returns a null user.

This is configurable in Authentication → Settings → JWT expiry in the Supabase dashboard. Shorter values (24 hours, 7 days) tighten the window for stolen-cookie abuse; longer values (60 days, 90 days) reduce the friction of repeated sign-ins. Pick consciously: a longer refresh token is a longer window for a stolen cookie to remain useful.

For apps where users interact daily (dashboards, SaaS), the 30-day default rarely fires. For apps with bursty usage (consumer, seasonal), it fires more often than you'd expect — and the user blames the app, not Supabase.

The default confirmation OTP lifetime is 24 hours. After that, the link silently 404s. Users who delayed their email — and people do — hit a dead link and assume the app is broken.

Fixes that work:

  • Raise the confirmation expiry window (commonly 3-7 days for a public app)
  • Show a clear "Link expired" page with a one-click "Resend" button
  • Send a reminder email at hour 22 if the user has not yet confirmed

The reminder email is the highest-leverage of the three: it cuts unconfirmed-account support load because the user finishes the flow without needing to re-trigger it.

5. RLS does not protect you from the service role key#

The SUPABASE_SERVICE_ROLE_KEY bypasses RLS entirely — by design, so that admin scripts can do their job. The failure mode is when a developer imports a "convenient" admin client into a Server Component "because it was already there," and the rest of the page now queries the database as a role with no row-level restrictions.

Rule: the service role key lives in its own client file (lib/supabase/admin.ts), is only imported in code paths that are explicitly admin, and CI greps for any new file that imports it without a specific allowlist entry.

6. Auth state changes do not fire in server components#

onAuthStateChange is a client-side listener. In a server component, you have to call getUser() explicitly each render. There is no event bus.

The mental model that works:

  • Server: pull. Each request, call getUser() (or getSession() if appropriate).
  • Client: push. Subscribe to onAuthStateChange for live UI updates.

Mixing them produces bugs where the user signs out in one tab and another tab still shows them as logged in until they refresh.

Safari's Intelligent Tracking Prevention sometimes deletes auth cookies on a 7-day inactive cycle. This is documented WebKit behavior, but the symptom is invisible until users report "Safari logs me out every week."

Mitigations:

  • Set the auth cookie's domain correctly — match the exact subdomain your app is on
  • Avoid cross-subdomain auth flows where possible
  • For PWAs/installed apps, this is much less of an issue
  • Document it for users with a "Why am I being logged out?" help article

iOS Mail (and Gmail's bot) follows magic links to generate previews. If your magic link is single-use, the preview consumes the token, and the user clicks an expired link.

Fix: configure Supabase to use verifyOtp with a 6-digit code instead of magic links, OR use a two-step flow where the email contains a link that lands on a page with a "Click to sign in" button (which then exchanges the token).

The second approach is more clicks but it survives every email client we have tested.

9. Email sending is slow on the free tier#

The default Supabase SMTP path is rate-limited and has noticeable delivery delay. At dev scale this is fine. At production scale, password resets arrive several minutes late and users churn.

Hook up a transactional provider (Resend, Postmark, SendGrid, AWS SES) in Authentication → Email Templates → SMTP Settings in the Supabase dashboard. Pricing for these providers is published on their sites and changes frequently; check the live pages for current rates:

10. The "anon" key is public — but it is not "safe"#

The anon key is meant to be public. It is in your client bundle. That is by design. But "public" does not mean "harmless."

Anyone with your anon key can:

  • Read any table that does not have RLS enabled or has a permissive policy
  • Insert into any table with a permissive INSERT policy
  • Call any RPC function

This is exactly the failure mode that fires whenever a developer ships a table without RLS: the bot scrapes the anon key out of the public bundle, finds the unprotected table, and writes to it. RLS on every public table is the only durable fix.

Rule: RLS is on for every table from day one. No exceptions. Use a pg_audit extension or just schedule a recurring CI check that fails if any table in public has rls_enabled=false:

sql
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
  AND NOT EXISTS (
    SELECT 1 FROM pg_class c
    JOIN pg_namespace n ON n.oid = c.relnamespace
    WHERE c.relname = pg_tables.tablename
      AND n.nspname = 'public'
      AND c.relrowsecurity = true
  );

If this query returns rows, RLS is missing somewhere.

11. MFA is a feature you build, not a feature you flip on#

Supabase ships TOTP-based MFA. The primitive is there. But "MFA enabled" in production means:

  • A UX for enrolling a TOTP factor
  • A UX for verifying it on sign-in
  • A backup-code system for when users lose their phone
  • An admin path to reset MFA if a user is locked out
  • Step-up auth for sensitive actions (using aal2 checks)
  • An audit log of MFA changes

That is several days of work. The cryptographic primitive is one API call. Treat it that way when you scope the feature.

The relevant API:

typescript
const { data: { id, totp } } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
// totp.qr_code is the base64 QR for the user's authenticator app
 
await supabase.auth.mfa.verify({
  factorId: id,
  challengeId: challenge.id,
  code: userEnteredCode,
})

Use getAuthenticatorAssuranceLevel() to check aal2 (MFA-verified) before showing sensitive UI.

What to apply from day one#

Three things to lock in early rather than retrofit later:

  • Put auth checks in middleware from day one. It scales. Adding it later means rewriting every protected route.
  • Wire a real SMTP from launch. The default works until it does not.
  • Centralize session reads behind one getCurrentUser() helper. Every team member uses it; nobody re-invents session reads. Wins consistency for free.

The bottom line#

Supabase Auth is the right choice for most Next.js + Supabase apps. It is cheaper than Auth0 at the lower MAU tiers, faster to integrate than Cognito, and tightly coupled to Postgres in a way that makes RLS a joy. Pricing for both providers is published on their sites and changes frequently — check supabase.com/pricing and auth0.com/pricing before budgeting.

Auth is one of those areas where the wrong default config turns into a security incident. The eleven items above are the patterns the community has surfaced most often. Apply them on day one and the integration code stays boring, which is the goal.

If you are in the early stages, the Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15 covers the full setup. The Why Your Supabase RLS Policies Are Silently Failing (And How to Debug Them) post is a useful companion read.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.