Building a Multi-Role Authentication System in Next.js with Supabase

When I started building our partner management platform, authentication seemed straightforward. Users log in, see their dashboard. Done.
Then the requirements changed. Partners needed one view. Their managers needed another. Support staff needed access to specific ticket types. Admins needed to see everything.
Suddenly, my "simple" auth system was a mess of conditional renders and security gaps. If you're dealing with multiple user types that need different access levels, here's what actually works.
The Real Problem Nobody Talks About
Most auth tutorials stop at "user logged in successfully." That's not even half the battle.
The hard part is answering these questions:
Where does each user type go after login?
How do you prevent a partner from manually typing
/adminin the URL?How do you ensure support users only see their assigned ticket types?
What happens when you need to deactivate a user mid-session?
You can't solve this with {isAdmin && <Button />}. That's UI theater, not security.
The Architecture: Separate Tables vs Role Strings
I started with a single users table and a role column. Big mistake.
The problem:
users
- id
- email
- role: 'admin' | 'partner' | 'manager' | 'support'Looks clean. Falls apart immediately when:
Partners need
company_namebut support users needsupport_typeYou want to add partner-specific fields without bloating the users table
You need different validation rules per role
Your RLS policies become nested nightmare conditionals
What actually works:
Separate table per role. Each references the same auth user.
admins → auth_user_id
partners → auth_user_id (+ company_name, industry, etc.)
partner_managers → auth_user_id (+ assigned territories)
support_users → auth_user_id (+ support_type, active status)Why this matters:
Each role gets exactly the fields it needs
Database queries become cleaner
RLS policies are straightforward
Adding a new role doesn't touch existing tables
The tradeoff? You query multiple tables. But that's a tiny performance hit compared to the maintenance headache you avoid.
Login Flow: The Priority Problem
When someone logs in, you need to check which role they have. Simple query, right?
Not if someone exists in multiple tables (shouldn't happen, but bugs exist).
The naive approach: Check tables randomly until you find one. Maybe they're a partner. Maybe they're admin. Who knows?
The production approach:
Priority-based checking with explicit order:
Admin first (highest access)
Support users (but only if active)
Partner managers
Partners last
This isn't about probability—it's about intentional hierarchy. If someone somehow ends up in multiple tables, you know exactly where they land.
The active status check for support users? That's not optional. You need the ability to deactivate accounts without deleting data. Learned that the hard way when a support user left the company and we needed to audit their ticket history.
Middleware: Why Client-Side Checks Don't Matter
Here's what broke my first implementation:
I checked roles at login, redirected to the right dashboard, and called it done. Then I realized anyone could type a URL and access routes they shouldn't see.
Your Next.js app runs client-side code that can be manipulated. Browser devtools exist. People will try things.
The solution:
Middleware that validates every single request before it reaches your page components.
Not just "is there a session?" but "does this user's role belong on this route?"
Admin route? Check the admins table.
Support route? Check support_users AND active status.
Partner route? Check partners table.
No match? Immediate redirect.
The performance question:
Yes, this hits your database on every request. In practice, with connection pooling and indexes on auth_user_id, we're talking 50-100ms overhead. You won't notice it. Your users won't notice it.
What they WILL notice is if you skip this and someone accesses data they shouldn't.
Row-Level Security: The Only Security That Actually Works
Middleware protects routes. But what about API calls? What about direct database access?
This is where most implementations completely fail.
The scenario everyone misses:
Your frontend checks roles and hides buttons. Great. But your API endpoint for fetching tickets doesn't check anything—it just returns all tickets. A partner opens the browser console, sees the API call, modifies the request parameters, and suddenly they're viewing every other partner's tickets.
The fix:
Database-level security that can't be bypassed.
With Row-Level Security, you write policies directly on tables. The database automatically filters queries based on who's asking.
Example concept:
When a support user queries tickets, the database checks:
What's their
support_type?Are they
active?Only return tickets matching their type
When a partner queries tickets, the database checks:
What's their
partner_id?Only return tickets they created
When an admin queries tickets:
Return everything
You write SELECT * FROM tickets everywhere in your code. The database handles the filtering.
Why this is non-negotiable:
Even if your frontend breaks, even if someone bypasses middleware, even if your API has a bug—the database won't return data they shouldn't see.
I sleep better knowing this layer exists.
Creating Users: The Transaction Problem
When admins create new support users, you need to:
Create an auth user (email/password)
Create a record in
support_userstableRoll back everything if either step fails
Do this in your Next.js API route? Now you've exposed admin credentials to the browser.
The pattern that works:
Server-side function with privileged access that:
Validates the requester is actually an admin
Creates both records in a transaction
Handles rollback automatically if anything fails
Never exposes service keys to the frontend
Call it from your admin panel with the current user's session token. The function validates they're an admin before executing anything.
This isn't overkill—it's the minimum viable security for user creation.
The Mistakes That Cost Me Time
Mistake 1: Only checking roles at login
A partner stayed logged in, got curious, typed /admin in the URL bar. The page loaded. They saw everything for about 30 seconds before I caught it during testing.
Fixed it with middleware. Never shipped without it again.
Mistake 2: Trusting UI-only checks
{isAdmin && <DeleteButton />} is not security. The API endpoint was still publicly accessible. Someone could delete data by calling the endpoint directly.
Fixed it with RLS. Now the database refuses the query even if the endpoint is called.
Mistake 3: Not handling inactive users
Support user gets deactivated but their session is still valid. They keep working for hours until their token expires.
Fixed it by checking active status in middleware and at login. Deactivation is now immediate.
Mistake 4: Assuming clean data
"Nobody will ever be in two role tables at once." Wrong. Bugs happen. Data migrations go wrong. Test accounts get messy.
Fixed it with priority-based checking. Now there's a deterministic outcome even if data is messy.
Conclusion
Multi-role authentication isn't the feature that makes your product demo well. It's the infrastructure that lets you ship everything else confidently.
I spent two weeks getting this right. Now I haven't touched the auth code in six months. It just works.
That's what good architecture does—it disappears into the background and lets you focus on features that actually matter to users.