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.
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.usersas opaque - Create a
public.profilestable withid 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
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.
4. Email confirmation links expire in 24 hours#
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()(orgetSession()if appropriate). - Client: push. Subscribe to
onAuthStateChangefor 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.
7. Cookie-based sessions and Safari ITP do not get along#
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
domaincorrectly — 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
8. Magic links break on iOS link previews#
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:
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
aal2checks) - 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:
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
Continue Reading
The Supabase Auth Pattern That Saved My Startup From a $50K Security Audit Failure
How we went from failing enterprise security requirements to passing SOC 2 compliance in 6 weeks. The authentication architecture patterns that actually work at scale.
Build a Full-Stack App with Next.js and Supabase in 20 Minutes
Build a complete full-stack application with Next.js 15 and Supabase from scratch. Authentication, database, CRUD operations, and deployment — all in 20 minutes.
Debugging Supabase RLS Issues: A Step-by-Step Guide
Master RLS debugging techniques. Learn how to identify, diagnose, and fix Row Level Security policy issues that block data access in production.