Supabase RLS with Keycloak: the architecture for your client portal

Keycloak answers who your user is; Postgres with Row Level Security decides in the database which rows they get to see. The link between them is the JWT from Supabase Auth, enriched via the Custom Access Token Hook. This article shows you the architecture, the three integration paths, and four pitfalls from our client portal projects.
10 min readMatthias RadscheitMatthias Radscheit
Happycodingen-US

TL;DR

Keycloak answers who your user is; Postgres with Row Level Security decides in the database which rows they get to see. The link between them is the JWT from Supabase Auth, enriched via the Custom Access Token Hook. This article shows you the architecture, the three integration paths, and four pitfalls from our client portal projects.

  • Separate authentication from authorization: Keycloak verifies identity, RLS policies in Postgres decide data access row by row.
  • Supabase third-party auth does not support Keycloak (only Clerk, Firebase, Auth0, Cognito, WorkOS) — the official path runs through Supabase Auth as the token issuer.
  • Authorization data belongs in app_metadata or in claims from the Custom Access Token Hook — users can change user_metadata themselves.
  • A Keycloak logout does not end the Supabase session: by default the JWT stays valid for another hour, and revoked permissions only arrive with the next token.
  • Put an index on every column a policy filters on and use the (select auth.uid()) pattern — otherwise your portal will not scale.

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:

PathToken issuerKeycloak?Typical use
Built-in Keycloak providerSupabase AuthYes, officially documentedStandard case: one realm, one portal
Custom OIDC provider (since May 2026)Supabase AuthYes, generic via issuer URLSeveral realms, special configurations
Third-party authExternal IAMNoExisting 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.

Frequently asked questions

Can Supabase accept Keycloak JWTs directly?
Not via the official third-party auth path: as of October 2026, it supports only Clerk, Firebase Auth, Auth0, AWS Cognito, and WorkOS. Instead, you connect Keycloak as a login provider in Supabase Auth — through the built-in Keycloak provider or, since May 2026, as a custom OIDC provider. Supabase Auth then issues the JWT for the database.
How do Keycloak roles get into my RLS policies?
Through the Custom Access Token Hook: a Postgres function that Supabase Auth calls before issuing each token. There you write roles and tenant ID into the token as claims, and your policies then read them with auth.jwt(). Important: never read authorization data from user_metadata, because users can change that field themselves.
What happens when I revoke a user's permissions in Keycloak?
At first, nothing: the Supabase session keeps running, and the access token stays valid for one hour by default. The new permissions only take effect at the next token refresh or login. For hard blocks you therefore need a shorter token lifetime or an additional server-side check at the critical points.
Does RLS make my database slow?
Only if implemented carelessly. Postgres evaluates policies per row. Two habits keep it fast: wrap functions like auth.uid() in a select so Postgres caches the result per query, and put an index on every column a policy filters on.
Is Supabase Auth alone not enough, without Keycloak?
For consumer apps with email or social login: yes, often. Keycloak pays off as soon as business customers expect SSO through their own directory, you want several applications attached to one identity, or compliance rules demand a self-hosted identity provider in the EU.
What does this stack cost?
Supabase Pro starts at 25 US dollars per month including 100,000 MAU; Keycloak itself is open source and license-free but creates hosting and maintenance effort (as of October 2026). You will find the full calculation including operating costs in our stack article on Supabase and Keycloak.

Sources

Related articles

Open for select projects

Let's talk about your project

Book a no-obligation call, send us an email, or use the form – we'd love to hear from you.

150+
Completed projects
15
Years of experience
8
Senior‑level team members