Skip to content
All posts

Role-Based Access Without an IAM Product

2026-04-04

Every portal project eventually hits the access control question. Someone says "permissions" and an hour later you're comparing Okta, Auth0, and Microsoft Entra, wondering whether a 30-person team really needs a $6-per-user-per-month identity platform. Usually, it does not. RBAC at this scale fits in one database table and a middleware function.

What RBAC Actually Means at This Scale

Role-based access control is three things stacked together, and most teams only need the first two.

The first piece is roles. A role is a label attached to a user: admin, manager, employee, client. One user gets one role in most small portals. Give someone two roles and you've opened the door to conflict resolution logic you don't want to maintain.

The second piece is permissions: the specific actions a role can take, like view_invoice, edit_invoice, or export_report. You don't have to enumerate every permission up front. Start with coarse role checks and split them out as real edge cases appear.

The third piece is row-level scoping: restricting which rows of a table a user can see even when their role allows the action. A manager can view employees, but only on their team. A client can view invoices, but only their own. This is where most portal bugs live, and where enterprise IAM products offer you nothing. Row-level scoping is application logic, not identity provider configuration.

If you remember one thing: Okta sells you the first two. The third is your problem either way.

The Three Role Patterns You'll Actually Use

Ninety percent of the portals we build fall into one of three shapes. Pick the simplest one that fits and resist the urge to pre-build for a future that may never arrive.

Admin / User. Two roles. Admins do everything, users do the subset you let them do. This is the right shape for internal tools where the line between power users and everyone else is clear: employee portals, inventory apps, internal dashboards. Under ten admins and under a hundred users, you're done thinking about this.

Admin / Manager / User. Three roles, with managers acting as a middle tier that can see and edit within their own scope but cannot cross it. This is where row-level scoping starts to earn its keep: a manager has access to their team, their region, or their client book, not everyone's. If you find yourself wanting a fourth role, stop and ask whether it's really a new role or just a permission toggle on an existing one.

Multi-tenant with org scoping. Every user belongs to an organization and can only see data inside that org. Admins inside an org manage their own users. You, the platform operator, sit above all orgs as a super-admin. This is the pattern for client portals, investor dashboards, and anything serving multiple businesses through one app. A single org_id column on every tenant-owned table covers most of it.

When You Genuinely Need SSO or Okta

There are three situations where the conversation changes and an actual IAM product starts to pay for itself.

The first is enterprise customers contractually requiring SSO. If you're selling into companies that demand SAML login against their Okta or Azure AD, you need SSO. That's a procurement requirement, not an architecture decision. Libraries like NextAuth, Passport, or Auth.js handle SAML and OIDC directly without buying an IAM product for yourself.

The second is more than a few hundred users with real provisioning churn. Once you're onboarding and offboarding people weekly, a centralized directory with SCIM provisioning saves real operational time. Under that threshold, manual admin invites are faster than configuring a directory sync.

The third is regulated industries with audit requirements (healthcare, finance, government) where you need MFA enforcement, session policies, and access logs that satisfy an auditor. You can build these, but buying them off the shelf is usually cheaper than documenting your own implementation for a SOC 2 review.

Outside those three, you are almost certainly paying for features you will never use.

The One-Table Implementation

Here is the whole thing, shippable in an afternoon.

You need a users table with at minimum an id, email, password_hash (or oauth_provider_id), and a role column that holds a single enum value: 'admin', 'manager', 'user', whatever your pattern calls for. If you're doing org scoping, add an organization_id foreign key on the same row. That's the schema.

On every request, your auth middleware loads the session, looks up the user, and attaches both the user record and their role to the request context. Every route handler then checks if (user.role !== 'admin') return 403 or delegates to a small permission helper like can(user, 'edit_invoice', invoice). Row-level scoping becomes a WHERE organization_id = ? clause in your queries, not a separate system, just a discipline you enforce in your data access layer.

Permission checks belong in two places: the API layer (the authoritative check, always) and the UI layer (purely cosmetic, to avoid showing buttons that will 403). Never trust the client. A user who edits their browser's JavaScript to reveal a hidden button should still get rejected at the API.

If you later need finer-grained permissions, add a permissions table keyed by role, load it on login, cache it in the session, and replace your role checks with permission checks. You will almost never need this before year two.

The whole pattern fits in roughly 200 lines of server code, survives dozens of users, and costs zero dollars a month. Build the simple thing, scope the database queries correctly, and revisit the question when you have an actual enterprise contract on the line.

Talk to us about your portal's access model and we'll tell you honestly whether you need Okta or a single database column.

Loading accessibility tools.