I Used Supabase Auth in Production for a Year. Here Are 11 Things I Wish I Knew.
technology

I Used Supabase Auth in Production for a Year. Here Are 11 Things I Wish I Knew.

One year, 50K users, and a surprising number of 2 a.m. pages. Here is what I would tell my past self before pushing Supabase Auth to production.

2026-04-20
11 min read
I Used Supabase Auth in Production for a Year. Here Are 11 Things I Wish I Knew.

I Used Supabase Auth in Production for a Year. Here Are 11 Things I Wish I Knew.#

We launched our SaaS on Supabase Auth in early 2025. A year and 50K users later, I have a list of things I would tell my past self before going live.

This is not a "Supabase bad" post. Supabase Auth is genuinely excellent — it is faster to ship with than Auth0, cheaper than Cognito, and integrates beautifully with Postgres RLS. But there are specific traps that the docs do not warn you about strongly enough.

Here is the list.

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

For the first six months I used getSession() in middleware to check if a user was logged in. It was fast. It worked.

Then a security audit pointed out: getSession() reads the access token from the cookie and returns whatever is in it. It does not verify the token's signature. A user who had their cookie stolen could continue using the app indefinitely, because we never asked Supabase to validate.

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.

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#

I tried to extend auth.users with custom columns. The migration failed because auth.users is owned by the Supabase auth schema and not freely mutable. I tried to JOIN against it from RLS policies. It worked, but it made the query plans ugly.

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, your user is signed out. They will not warn you about this. They will silently log out and you will get a flood of "I logged in this morning, why do I need to log in again?" tickets.

You can configure this in Authentication → Settings → JWT expiry. We set our refresh token to 90 days for an internal tool and 30 days for a public-facing app.

But know what you are setting. A longer refresh token = longer window for cookie theft to be useful.

Twice we had support tickets where users said "the link doesn't work." They had clicked the link 30 hours after signing up. The link silently 404'd because the token was already expired.

Fixes that helped:

  • Set the email confirmation expiry longer (we use 7 days)
  • Show a clear "Link expired" page with a "Resend" button
  • Send a follow-up email at hour 22 if the user has not confirmed

The third one cut "I cannot log in" tickets by 80%.

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

This was an embarrassing self-inflicted bug. We built an admin script that used SUPABASE_SERVICE_ROLE_KEY. The service role key bypasses RLS entirely.

Then a junior dev imported the same client into a Server Component "because it was already there." Suddenly every user's data was visible to every other user. Caught it in a code review before it shipped, but barely.

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

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 helped me:

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

If you mix them, you get bugs where the user logs 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 behavior, but I did not connect the dots until users reported "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 is rate-limited and has noticeable delivery delay. At dev scale this is fine. At production scale, your password resets arrive 5 minutes late and users churn.

Hook up your own SMTP (Resend, Postmark, Sendgrid) in Authentication → Email Templates → SMTP Settings. We use Resend; sub-second delivery, $20/mo for plenty of volume.

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

We had a signups table without RLS. Some bot scraped our anon key from the bundle and inserted 50,000 rows of garbage data over a weekend.

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 I got right#

To balance the list — three things I wish I had been even more aggressive about:

  • Putting auth checks in middleware from day one. It scales. Adding it later means rewriting every protected route.
  • Using a real SMTP from launch. The default works until it does not.
  • Having a single getCurrentUser() helper. Every team member used it; nobody re-invented session reads. Wins consistency for free.

The honest summary#

Supabase Auth is the right choice for 90% of the apps that people build with Next.js + Supabase. It is cheaper than Auth0, faster to integrate than Cognito, and tightly coupled to Postgres in a way that makes RLS a joy.

But auth is one of those areas where the wrong default config turns into a security incident. The 11 items above are the ones that actually mattered in our production. Apply them on day one and you will avoid the ones that almost mattered in ours.

If you are in the early stages, the [INTERNAL LINK: supabase-auth-complete-session-middleware-guide] covers the full setup. The [INTERNAL LINK: supabase-rls-silent-failures-debug] post is the one I send to every new dev who joins.

What did your production lessons look like? Drop a comment.

Frequently Asked Questions

|

Have more questions? Contact us