Skip to main content
Back to Blog
architecture

The Authentication Pattern Every Developer Should Know (But Most Learn the Hard Way)

7 February 20268 min read
AuthenticationReactNext.jsSupabaseArchitectureSecurity
Share:

A Note on Expertise

I'm not writing as an "expert" or claiming to have all the answers. I'm a builder sharing my journey on what worked, what didn't, and what I learned along the way. The tech landscape changes constantly, and with AI tools now available, the traditional notion of "expertise" is evolving. Take what resonates, verify what matters to you, and forge your own path. This is simply my experience, offered in the hope it helps fellow builders.

If you've ever built an app that works perfectly with one test user, then completely breaks when real users start logging in, you're not alone. In fact, you've just experienced one of the most common rites of passage in software development.

I recently encountered this exact problem while building Havnwright, a renovation platform. Everything worked great in development. Then multiple users started creating projects simultaneously, and suddenly sessions were getting mixed up, data was leaking between users, and the whole authentication layer became a house of cards.

After debugging for hours, I discovered a pattern that would have prevented all of it. It's called Centralized Auth with Guarded Data Access, and every mature web application uses some version of it.

The Problem: Death by a Thousand Auth Checks

Here's how most projects start (mine included):

  • Each feature gets built independently
  • Each component fetches its own auth state
  • Each database query manually adds a user_id filter
  • It works fine with one user testing

Then you go to production with multiple users and concurrent sessions, and everything breaks.

Why? Because you have multiple onAuthStateChange listeners competing with each other, race conditions everywhere, and no single source of truth for "who is the current user?"

The Solution: Three Layers of Defense

The fix isn't complex, but it requires thinking architecturally rather than feature-by-feature. Here's the pattern:

Layer 1: Application Layer (Single AuthProvider)

Every major framework has a single place where "who is the current user?" lives:

| Framework | Pattern | |-----------|---------| | Django | request.user | | Rails | current_user | | Laravel | Auth::user() | | React | useAuth() hook |

In React with Supabase, this means having one AuthProvider at the root that all components consume. Supabase's own documentation explicitly warns against having multiple onAuthStateChange listeners because of the race conditions it creates.

// ✅ Good: Single AuthProvider at root <AuthProvider> <App /> </AuthProvider> // ❌ Bad: Multiple components managing their own auth state <ComponentA> {/* has its own onAuthStateChange */} <ComponentB> {/* has its own onAuthStateChange */}

Layer 2: Query Layer (Structural Enforcement)

Here's a hard truth: never trust individual developers to remember to add the user filter. Not because developers are careless, but because humans make mistakes, and one forgotten .eq('user_id', userId) in one query can expose another user's data.

The solution is a query helper that enforces user scoping structurally:

// Instead of this (easy to forget): const { data } = await supabase .from('projects') .select('*') .eq('user_id', userId); // Use this (impossible to forget): const { data } = await createAuthQuery('projects').select('*'); // user_id filter is automatically applied

This is the same concept as:

  • Django: Queryset filtering (Model.objects.filter(user=request.user))
  • Rails: Default scopes
  • Enterprise Java: Tenant isolation

Layer 3: Database Layer (Row Level Security)

PostgreSQL's Row Level Security (RLS) is your last line of defense. Even if your application code has a bug, the database itself will refuse to return rows that don't belong to the current user.

-- Users can only see their own projects CREATE POLICY "Users can only access own projects" ON projects FOR ALL USING (user_id = auth.uid());

This is not optional security theater. It is the same concept as database-level access control in any enterprise system. If someone finds a way to bypass your application layer, RLS stops them at the database.

Why This Pattern Matters for "Vibe Coders"

If you're new to development and building with AI assistance (what some call "vibe coding"), this pattern is especially important to understand. AI can help you build features quickly, but it typically builds them in isolation. Each prompt generates code that solves the immediate problem without considering how it interacts with other parts of your system.

This leads exactly to the "death by a thousand auth checks" problem:

  • AI generates a component that fetches user data ✓
  • AI generates another component that fetches different data ✓
  • Both work in isolation ✓
  • Both break when used together ✗

Understanding this pattern helps you:

  1. Review AI-generated code with a critical eye for auth handling
  2. Structure prompts that reference your existing auth patterns
  3. Catch issues before they become production bugs

Is This Approach Standard?

Yes. Every piece of it follows established patterns:

| Component | Pattern | Industry Standard | |-----------|---------|-------------------| | Single AuthProvider | React Context | ✅ | | Query helper | Data Access Layer | ✅ | | RLS | PostgreSQL feature | ✅ | | Loading timeouts | UX resilience | ✅ |

The only thing specific to your stack is the implementation details (Supabase client API, Next.js middleware). The architecture is the same whether you're building with Firebase, AWS Cognito, Auth0, or any other auth provider.

The Bottom Line

This isn't an experimental approach. It's the approach that companies like Vercel, Stripe, and Notion use internally. The difference is they usually set it up from day one because they have senior engineers who've been burned by the alternative before.

If you're setting it up now, after discovering the problem, you're actually ahead of most teams. Most never fix it properly,they just keep patching individual bugs until the codebase becomes unmaintainable.

The best time to implement this pattern was at the start of your project. The second best time is now.


Have questions about implementing this in your stack? Get in touch. I'm always happy to discuss architecture patterns.

About the Author

Alireza Elahi is a technical founder building products that solve real problems. Currently working on Havnwright and Privev.