One portal, two questions: who are you, and what may you see?
Every B2B client portal answers two questions on every click: Who are you? And which data are you allowed to see? The first is authentication, the second authorization. In many projects, a single layer handles both: the application code. That is exactly where the leaks start, the ones where tenant A suddenly sees tenant B's orders.
My answer for portals with several business customers: separate the two questions architecturally. An external IAM answers the identity question; Row Level Security (RLS) in Postgres answers the access question, per table row, right in the database. This article shows you the architecture behind it: the building blocks, the integration, the policies, and four pitfalls from our project work.
I do assume some technical understanding: you don't need to write SQL, but you should want to know why your development partner puts access control into the database. All prices and version numbers are dated, and every statement about the mechanics comes from the official Supabase and Keycloak documentation.
The architecture in words: Keycloak up front, Postgres in the back, JWT in between
The architecture has three layers. Up front sits Keycloak as the identity provider: it holds the user directory, renders the login, speaks OIDC and SAML, and delivers MFA all the way to passkeys. Keycloak is open source, a CNCF incubating project since April 2023, and currently stands at version 26.8.0 (as of October 2026).
Behind it sits Supabase as the data layer: managed Postgres with an automatically generated API. Every table in your portal carries RLS policies: SQL rules that decide on every query, row by row, whether the requesting user may see it. Authorization lives in the database instead of the application code.
Operationally, this split means: Supabase runs the database for you, while you run Keycloak yourself or have it run for you. Two components, clearly separated responsibilities: a Keycloak update never touches your data, and a database migration never touches your login.
The link between them is the JSON Web Token (JWT): a signed credential describing the logged-in user and their claims, such as user ID, roles, and tenant. The flow on every login:
- Your portal sends the user to the Keycloak login, optionally via SSO into your customer's company directory.
- Keycloak confirms the identity; Supabase Auth exchanges the authorization code for a session.
- Supabase Auth issues the JWT, which your frontend sends along with every API request from then on.
- Postgres reads the claims from the JWT and evaluates every RLS policy against them.
The payoff of this arrangement: even if an API route forgets a filter, the database will not hand out another tenant's data. RLS is the second line of defense — it holds even when the first one wobbles.
How Supabase accepts external identities: three paths, two lead to Keycloak
First, the question I hear most often in initial calls: “Does Supabase simply accept the tokens from Keycloak?” The short answer: not directly. The Supabase API only trusts JWTs from issuers you have explicitly configured. There are three paths for that — and only two of them lead to Keycloak.
Path 1: the built-in Keycloak provider
Supabase Auth lists Keycloak as an official login provider. You create an OIDC client in Keycloak with access type “confidential”, enter your Supabase project's callback address as the redirect URI, and store the client ID and secret in the dashboard. Since Keycloak 22, your frontend also has to pass the openid scope at login; the call itself is a one-liner with signInWithOAuth.
Path 2: custom OIDC provider for special cases
Since May 2026, Supabase Auth additionally connects any standards-compliant OIDC provider. You only store the issuer URL of your Keycloak realm; Supabase finds the endpoints and signing keys itself via the discovery document. This path pays off when you run several realms or need settings the built-in Keycloak connector does not cover.
Path 3: third-party auth — not meant for Keycloak
Supabase also offers third-party auth: the data API accepts an external IAM's JWTs directly, and Supabase Auth steps aside. Exactly five providers are officially supported: Clerk, Firebase Auth, Auth0, AWS Cognito, and WorkOS (as of October 2026). Keycloak is not on the list. Anyone promising you a “direct” Keycloak connection to the data API is leaving the officially supported path.
Technically, Supabase requires asymmetrically signed JWTs with a kid header for this third path, used to find the matching signing key. That also explains the short list: the data API can only verify external tokens, never issue them itself, and so far Supabase grants this trust only to providers it has integrated by name.
Price-wise, this third path comes in at 0.00325 US dollars per third-party MAU above your plan's quota, according to the Supabase pricing page (as of October 2026). For the overview:
| Path | Token issuer | Keycloak? | Typical use |
|---|---|---|---|
| Built-in Keycloak provider | Supabase Auth | Yes, officially documented | Standard case: one realm, one portal |
| Custom OIDC provider (since May 2026) | Supabase Auth | Yes, generic via issuer URL | Several realms, special configurations |
| Third-party auth | External IAM | No | Existing IAM at Clerk, Auth0, Firebase, Cognito, or WorkOS |
Remember this: with a Keycloak setup, the JWT your database verifies is always issued by Supabase Auth. Keycloak stands at the front door, not next to the database.
RLS policies on JWT claims: authorization that lives in the database
On to the second half of the architecture: the policies. Supabase gives you two functions in Postgres that let a policy read the requester's token.
auth.uid() and auth.jwt(): the two readers
auth.uid() returns the user ID from the JWT; auth.jwt() returns the complete token as JSON. A tenant policy then looks like this: using ((select auth.jwt() -> 'app_metadata' ->> 'tenant_id') = tenant_id). Every row carries its tenant ID, and only rows belonging to the user's own tenant pass the filter.
You model roles the same way: a policy for write access checks something like (select auth.jwt() -> 'app_metadata' ->> 'portal_role') = 'admin', while read access stays open to all members of the tenant. A few lines of SQL become a permission model that applies to every access path: portal frontend, admin dashboard, and any future application on the same database.
Two details from the Supabase documentation are worth adopting from day one. First: without a logged-in user, auth.uid() simply returns null, so every policy needs an explicit null check. Second: the (select auth.uid()) pattern lets Postgres cache the result per query instead of evaluating it for every row.
app_metadata, not user_metadata
The token carries two metadata buckets, and the distinction is security-critical. Logged-in users can change user_metadata themselves via supabase.auth.update(); the Supabase docs explicitly say it is not a good place for authorization data. Store roles there and you allow self-promotion to admin. Roles and tenant IDs belong in app_metadata: that field is writable only server-side.
The Custom Access Token Hook
That leaves the question of how Keycloak's knowledge gets into the Supabase JWT. The tool for this is the Custom Access Token Hook: a Postgres function or HTTP endpoint that Supabase Auth calls before issuing each token, and that may add its own claims. The hook must not remove required claims such as sub, exp, and role — otherwise Supabase rejects the token.
In our portal projects, this hook reads the role and tenant assignment from a dedicated table and writes them into the token as claims. The truth about permissions then lives in exactly one place that no user can write to.
Four pitfalls from our portal projects
What follows is not in any documentation: it is project experience from client portals I have built with happycoding on this stack. Four points cost us real time.
1. Two session worlds: a Keycloak logout does not end the Supabase session
Keycloak and Supabase keep separate sessions. If your customer blocks an employee in their directory, or that employee logs out of Keycloak, the Supabase session keeps running at first: per the Supabase docs, the access token is valid for one hour by default, and refresh tokens extend the session beyond that. Revoked permissions only arrive with the next freshly issued token.
How we handle it: choose the token lifetime deliberately (the Supabase docs advise against values under five minutes) and verify hard blocks server-side on top. When offboarding an employee, “sometime within the next hour” occasionally is not good enough.
2. Claim mapping: roles do not travel on their own
Our expectation in the first project: after login, the Keycloak roles simply appear in the Supabase JWT. The reality: what the login provider reports about a user does not automatically become a trusted authorization claim. You build that transfer yourself, cleanly via app_metadata or the access token hook. Plan this mapping from the start; it is the real integration piece of this stack. Depending on the role model, it took us one to two development days, tests included.
3. Realm roles vs. client roles: two places in the token
Keycloak distinguishes realm-wide roles from client roles, a separate namespace per application. They land in different places in the Keycloak token (realm_access and resource_access), and protocol mappers decide per client which claims get written at all. A switch from realm roles to client roles once silently broke our mapping: the policies matched nothing, and users simply saw no data anymore.
The lesson: settle the role taxonomy before the first policy exists, and test your policies automatically against real tokens. An RLS mistake does not throw an error message — it just returns empty results.
4. RLS performance: policies run per row
Postgres evaluates a policy against every candidate row. Without an index on the tenant column, every portal query becomes a sequential scan that grows with your data. In one of our projects, this only surfaced when a new customer started with several times the existing data volume. Our rule since then: an index on every column a policy filters on, and the select pattern from the first migration.
When this stack fits, and when it does not
To close, the honest assessment, because this architecture is no standard recipe for every project. It fits when your portal has these traits:
- Several tenants: business customers with strictly separated data, where a leak would have contractual consequences.
- SSO demand: your customers want to sign in with their own Entra ID or LDAP; that is exactly what Keycloak was built for.
- Data residency: you want to run the identity provider yourself in the EU instead of handing identities to a US SaaS.
- Modern login: MFA and passkeys should be configuration, not a custom build.
And when do I advise against it? For consumer apps with a simple email or social login, Keycloak is overhead: Supabase Auth alone is enough there, and the Pro plan already covers 100,000 active users for 25 US dollars a month (as of October 2026). Price in the operations honestly, too: Keycloak ships four minor releases a year that someone has to apply and test.
You will find the full cost calculation, including my general recommendation, in the stack article on Supabase and Keycloak: I deliberately will not repeat that part here.
Next steps
If you are planning a client portal with tenant separation, settle three things first: Do your customers need SSO into their own directory? How many tenants start in year one? And who runs the identity provider? With those three answers, the architecture decision usually stands after one conversation.
Building client portals on exactly this stack is part of our day-to-day work at happycoding. In a free initial consultation, we go through your requirements and sketch the architecture for your case. Book an appointment directly.
