Trending Posts

Auth0 Supabase SSO architecture diagram showing enterprise SAML connection routed through Auth0 Organizations into Supabase RLS

Every Auth0 and Supabase tutorial you'll find online solves the same toy problem: one user, one row, one owner_id column. That's fine until an enterprise prospect asks whether you support SSO through their Okta tenant, and you realize the token bridge you copied from a blog post has no idea what an organization even is. Suddenly you're staring at RLS policies built for individual users, trying to figure out how to isolate data by tenant instead.

This is the gap almost nobody writes about. The official Auth0 Next.js integration guide is genuinely good at explaining the cryptographic handshake between the two platforms, but it stops at a single user ID. It never touches Auth0 Organizations, SAML routing, or tenant-scoped RLS, which is exactly what you need once you're selling to companies instead of individuals. If you've already read our complete guide to multi-tenant Supabase architecture, this article is the authentication layer that feeds into it.

Quick Answer: Auth0 Supabase SSO for B2B apps requires routing enterprise SAML or OIDC connections into Auth0 Organizations, capturing event.organization.id in a Post-Login Action, and re-signing a fresh JWT with your Supabase signing secret since Auth0 and Supabase cannot share a signing algorithm directly. RLS policies then check current_setting('request.jwt.claims', true)::json->>'org_id' instead of a user ID, which is what actually isolates tenants rather than individual rows.

Table of Contents

  1. Why the Standard Auth0 + Supabase Tutorial Falls Apart for B2B
  2. Setting Up Auth0 Organizations for Enterprise Customers
  3. Mapping a SAML or OIDC Connection to an Organization
  4. Capturing Organization Context with a Post-Login Action
  5. Rebuilding the Token Bridge for Multi-Tenant Claims
  6. Writing RLS Policies That Check Tenant ID, Not User ID
  7. Mapping Auth0 Roles to RLS Permissions
  8. Quick Answers About Auth0 Supabase SSO
  9. Frequently Asked Questions

Why the Standard Auth0 + Supabase Tutorial Falls Apart for B2B

Simply put, the widely-referenced Auth0 and Supabase integration pattern was built for individual user ownership, not tenant isolation, and it silently breaks the moment you have two companies sharing the same database.


Diagram of Auth0 Organizations SAML flow into Supabase RLS via token bridge

Here's the pattern almost every guide walks you through. A user logs in through Auth0. A Next.js afterCallback hook grabs their sub claim, signs a new JWT with your Supabase signing secret, and an RLS policy checks that a custom auth.user_id() function matches the row's user_id column. It works. It's also a B2C pattern wearing a B2B costume.

The problem shows up the moment "Acme Corp" signs a contract and wants their ten employees, each authenticating through their own Okta tenant, to see Acme's data and nothing else. There's no organization in that token. There's no tenant boundary in that RLS policy. You'd have to bolt on a separate organization_members table and join against it on every single query, which is exactly the kind of join-heavy policy that custom JWT claims exist to eliminate.

Developers hit this wall constantly. One engineer on r/Supabase described building organization-level features like team invites and per-org roles, then noting that "Supabase auth handles individual users well but the organization layer doesn't exist" at the platform level, they had to build it themselves. That's the honest state of things: Supabase gives you a rock-solid RLS engine, but the organizational modeling has to come from somewhere else, and Auth0 Organizations is that somewhere else for teams that need real enterprise SSO.

The rest of this article covers the actual bridge: getting Auth0 Organizations set up, mapping SAML connections to them, and rewriting your token signing and RLS policies to check a tenant ID instead of a user ID.

Setting Up Auth0 Organizations for Enterprise Customers

Simply put, an Auth0 Organization is a container that represents one of your B2B customers, and it's the mechanism that lets you attach enterprise connections, roles, and branding to a specific company rather than to your whole user pool.

You create one Organization per B2B customer, not per user. Acme Corp gets one Organization. Their employees get added as members of it, either manually, through an invitation flow, or automatically via Just-In-Time provisioning when they authenticate through Acme's own identity provider for the first time.

Two things matter here that most tutorials skip entirely:

  • Organization ID is stable and unique. It's the value you'll eventually inject into your Supabase JWT as the tenant identifier, so treat it as the canonical tenant_id from day one, not an afterthought bolted on later.
  • Organizations support their own connection whitelist. You can restrict which identity providers are valid for a given Organization, which matters when Acme mandates that every login go through their Azure AD tenant and nothing else.

Once the Organization exists, the next step is attaching an actual enterprise identity provider to it, which is where SAML and OIDC connections come in.


Auth0 dashboard showing Acme Corp Organization with SAML connection attached

Mapping a SAML or OIDC Connection to an Organization

Simply put, you configure a SAML or OIDC enterprise connection at the Auth0 tenant level, then attach it to the specific Organization it belongs to, so logins through that connection land the user inside the correct company's boundary automatically.

The most common enterprise identity providers you'll be asked to support are Okta, Azure Active Directory, and Google Workspace, all of which Auth0 supports as enterprise connections through either SAML 2.0 or OIDC. The setup itself, exchanging metadata URLs, certificates, and entity IDs with the customer's IT team, is well documented in Auth0's SAML connection docs, so we won't repeat that here.

What matters for this article is the step after that: linking the connection to an Organization in the Auth0 dashboard, under that Organization's Connections tab. This is what makes Auth0 aware that "anyone who logs in through this SAML connection belongs to Acme Corp," rather than dropping them into your undifferentiated general user pool.

If Acme's IT department also wants automated user lifecycle management, Auth0 supports inbound SCIM provisioning for Azure AD SAML connections, which automatically adds and removes Organization members as employees join or leave Acme. Most teams don't need this on day one, but it's worth knowing it exists before you build a custom provisioning script to solve a problem Auth0 already handles.

With the connection mapped, a login through Acme's Okta tenant now resolves to a specific Organization inside Auth0. The next problem is getting that Organization ID out of Auth0's session and into the token your Next.js backend actually sees.

Capturing Organization Context with a Post-Login Action

Simply put, Auth0 exposes the Organization a user logged in through as event.organization inside a Post-Login Action, and you use that Action to write the Organization ID onto the ID token before it ever reaches your application.

This is the step the standard Auth0-to-Supabase tutorials skip completely, because they're not authenticating through an Organization flow at all. When a user does log in through an Organization, Auth0's Post-Login event object includes event.organization.id and event.organization.name, which is exactly the metadata you need to carry forward.

Here's the Action, written in Node.js, that grabs it and attaches it as a custom claim:

/**
 * Auth0 Post-Login Action
 * Captures the Organization ID and role, attaches them to the ID token.
 */
exports.onExecutePostLogin = async (event, api) => {
  if (event.organization) {
    // Namespaced claim required — Auth0 strips unnamespaced custom claims
    api.idToken.setCustomClaim(
      'https://rowistan.com/org_id',
      event.organization.id
    );

    // Pull the user's role within this Organization, if assigned
    const roles = event.authorization?.roles || [];
    api.idToken.setCustomClaim(
      'https://rowistan.com/org_role',
      roles[0] || 'member'
    );
  }
};

Two details here will save you hours of debugging. First, the claim namespace (https://rowistan.com/org_id) isn't optional decoration, Auth0 silently strips any custom claim that isn't namespaced as a URL. Second, this is written onto api.idToken, not api.accessToken. Auth0's access tokens follow OAuth conventions that reject arbitrary custom claims without extra configuration, while the ID token is built specifically to carry user and session context like this. If you're passing the access token to your Supabase bridge instead of the ID token, this claim simply won't be there, and you'll spend an afternoon convinced your Action isn't firing when it actually is.

Rebuilding the Token Bridge for Multi-Tenant Claims

Simply put, because Auth0 and Supabase can't share a JWT signing secret or algorithm, your backend has to catch the Auth0 session, pull out the organization claim you just added, and sign a brand new token that Supabase will actually trust.

This constraint isn't a workaround, it's a hard platform limitation on both sides. Supabase requires Auth0 tenants to run OIDC-conformant RS256 signing, and even then it won't natively verify a token signed with Auth0's key against your Postgres instance's expectations. As one developer put it on r/node while wiring this up: "neither Supabase or Auth0 allow for a custom signing secret to be set for their JWT. They also use different signing algorithms. Therefore, we need to extract the bits we need from Auth0's JWT, and sign our own to send to Supabase." That's the whole problem in one sentence, and it's exactly why a token bridge is required rather than optional.

Here's the multi-tenant version of that bridge, extending the standard pattern to carry the organization and role claims through:

import jwt from 'jsonwebtoken';
import { getSession } from '@auth0/nextjs-auth0';

// Signs a Supabase-compatible JWT carrying Auth0's org context
export async function getSupabaseToken(req, res) {
  const session = await getSession(req, res);
  if (!session) throw new Error('No active Auth0 session');

  const idToken = session.idToken; // decoded claims already attached
  const orgId = session.user['https://rowistan.com/org_id'];
  const orgRole = session.user['https://rowistan.com/org_role'];

  if (!orgId) {
    // User authenticated outside an Organization flow — reject or
    // route to a "no tenant assigned" state, don't sign a token
    // with a missing tenant boundary.
    throw new Error('No organization context on session');
  }

  const supabasePayload = {
    sub: session.user.sub,
    role: 'authenticated',   // required so PostgREST doesn't fall back to anon
    org_id: orgId,
    org_role: orgRole,
    aud: 'authenticated',
    exp: Math.floor(Date.now() / 1000) + 3600,
  };

  return jwt.sign(supabasePayload, process.env.SUPABASE_SIGNING_SECRET, {
    algorithm: 'HS256',
  });
}

Notice the explicit role: 'authenticated' claim. By default, a raw Auth0 JWT has no role field at all, and PostgREST treats any token without one as the heavily restricted anon Postgres role. This isn't a Supabase quirk, it's the documented default behavior, and it's a common reason developers see their queries silently return zero rows instead of an error: the query ran fine, it just ran as an anonymous user with no access.

The org_id and org_role claims are the actual payoff of everything in the previous two sections. They're what turns this from a single-user token bridge into a tenant-aware one, and they're what your RLS policies will check next.


Decoded JWT token showing org_id and role claims for Supabase RLS

Writing RLS Policies That Check Tenant ID, Not User ID

Simply put, once org_id is embedded in the JWT, your RLS policies stop comparing a row's owner against the logged-in user and start comparing a row's tenant column against the organization on the token, which is what actually enforces B2B data isolation.

Assume a standard multi-tenant table with an organization_id column, the same pattern covered in our pillar guide on multi-tenant schema design. Here's the policy that enforces isolation using the claim from the previous section:

-- Enable RLS on the table
alter table public.projects enable row level security;

-- SELECT: only rows belonging to the caller's organization
create policy "tenant_isolation_select"
on public.projects
for select
using (
  organization_id = (
    current_setting('request.jwt.claims', true)::json ->> 'org_id'
  )::uuid
);

-- INSERT: new rows must be tagged with the caller's own org_id
create policy "tenant_isolation_insert"
on public.projects
for insert
with check (
  organization_id = (
    current_setting('request.jwt.claims', true)::json ->> 'org_id'
  )::uuid
);

This is a meaningfully different policy from the B2C version. It's not asking "does this user own this row," it's asking "does this row belong to the organization on the caller's token." A single user can belong to exactly one organization per session, and every row they can see is scoped to that organization, full stop. There's no join against a membership table required, because the organization ID already lives on the token itself, courtesy of the Post-Login Action.

If you're already running the tenant_id IN() pattern for performance on high-volume tables, the same principle applies here: wrap the claim extraction in a stable SQL function so Postgres caches it per statement instead of re-parsing the JWT on every row.


Supabase dashboard showing tenant isolation RLS policies on the projects table

Mapping Auth0 Roles to RLS Permissions

Simply put, the org_role claim you captured earlier lets you restrict specific operations, like inserts or deletes, to admins within an organization while leaving read access open to everyone in that org.

Enterprise customers rarely want a flat permission model. Acme's IT admin wants to manage billing and invite teammates; their support staff just needs to view tickets. Auth0 Organizations supports assigning roles to members, and since you're already passing org_role through the token bridge, restricting write access is a one-line addition to the policy:

-- Only admins within the organization can delete projects
create policy "tenant_admin_delete"
on public.projects
for delete
using (
  organization_id = (
    current_setting('request.jwt.claims', true)::json ->> 'org_id'
  )::uuid
  and
  (current_setting('request.jwt.claims', true)::json ->> 'org_role') = 'admin'
);

This is where SAML-sourced Active Directory groups typically enter the picture. If Acme's IT department maps their internal "IT Admins" AD group to an Auth0 role during SAML attribute mapping, that role flows into event.authorization.roles in the Post-Login Action from earlier, straight through to this policy, with zero manual role assignment on your end. It's the same chain the whole article has been building: SAML connection to Organization to Post-Login Action to signed JWT to RLS policy, each step handing the next one exactly what it needs.

Quick Answers About Auth0 Supabase SSO

What Causes the Auth0 to Supabase Integration to Break for B2B Apps?

Simply put, the widely-copied integration tutorial only signs a user ID into the Supabase JWT, with no concept of an organization or tenant. Auth0 and Supabase also can't share a JWT signing secret, so a backend bridge has to re-sign the token, and if that bridge never captures organization context in the first place, there's nothing to isolate tenants against at the database layer. This matters most the moment you onboard your first customer with more than one employee.

Auth0 Supabase SSO at a Glance

AspectDetails
SymptomEnterprise SSO works for login, but RLS still isolates by user, not company
Root CauseStandard token bridge never captures Auth0's event.organization.id
FixPost-Login Action injects org_id into the ID token; RLS checks it via request.jwt.claims
Required Claim LocationID token, not access token — Auth0 strips unnamespaced custom claims from access tokens
Applies ToAuth0 Organizations + any current Supabase project using third-party JWT verification

When Does This Apply?

This applies once you're selling to companies rather than individuals and need real SAML or OIDC SSO through providers like Okta or Azure AD. If every user in your app is an independent individual account, the standard single-user Auth0 to Supabase bridge is still the right call, don't add Organizations complexity you don't need yet.

Pros and Cons of Bridging Auth0 Organizations into Supabase RLS

  • Pro: Enterprise-grade SAML/OIDC SSO without building a custom identity provider integration from scratch
  • Pro: Tenant isolation lives in the JWT claim, avoiding expensive membership-table joins in every RLS policy
  • Pro: Role mapping from AD groups flows through automatically once SAML attribute mapping is configured
  • Con: Adds a real backend dependency, your Next.js token-signing route becomes a single point of failure for every database request
  • Con: Supabase's native SAML SSO creates separate accounts for users who already have a password-based account, so pick one auth path per user and stick with it

Frequently Asked Questions

Does Supabase support SAML natively without Auth0?

Yes, Supabase offers native enterprise SSO with SAML 2.0 on paid plans. It's a valid option if you don't already use Auth0, but it doesn't provide Auth0's Organizations model, SCIM provisioning, or broader identity provider ecosystem.

Can I use Supabase only for the database and Auth0 for everything else?

Yes, this is the exact pattern this article covers. Auth0 handles identity, organizations, and SSO; Supabase handles Postgres and RLS. The token bridge is what connects the two, since Supabase never natively verifies an Auth0-issued JWT.

Why does my Auth0 custom claim disappear from the Supabase token?

Auth0 silently strips any custom claim that isn't namespaced as a full URL, and it doesn't attach arbitrary custom claims to access tokens by default. Use a namespaced claim like https://yourdomain.com/org_id and set it on the ID token, not the access token.

What JWT signing algorithm does Supabase require from Auth0?

Supabase requires the Auth0 tenant to be OIDC conformant and use RS256. It does not support HS256 or PS256 signed Auth0 tokens for third-party auth integration.

How do I get a user's organization ID into my Postgres RLS policy?

Capture event.organization.id in an Auth0 Post-Login Action, set it as a custom ID token claim, sign a new Supabase-compatible JWT containing that claim in your backend, then read it in RLS with current_setting('request.jwt.claims', true)::json->>'org_id'.

Is multi-tenant SSO with SAML supported through Auth0 Organizations?

Yes. You attach a SAML enterprise connection to a specific Auth0 Organization, so logins through that connection are scoped to that company automatically, which is the standard way to support one SSO connection per enterprise customer.

Do I need SCIM provisioning to use Auth0 Organizations?

No, SCIM is optional. It automates adding and removing Organization members when a customer's IT department changes staff, but manual invitations or Just-In-Time provisioning on first login work fine for smaller enterprise accounts.

Will mixing Supabase native SAML and Auth0 SSO cause account duplication?

If you use Supabase's native SAML and a user already has a password-based Supabase account, signing in through SAML creates a second, separate account rather than linking to the existing one. This is specific to Supabase's native SSO, not the Auth0 bridge pattern in this article.

Conclusion

The gap in nearly every Auth0 and Supabase guide isn't a missing code snippet, it's a missing layer. Token bridging alone gets you authentication. Auth0 Organizations, SAML connection mapping, and a Post-Login Action that captures event.organization.id are what get you real multi-tenant authorization on top of it. Once that organization claim exists on your signed JWT, your RLS policies stop guessing at ownership and start enforcing actual tenant boundaries, the same boundaries covered in more depth in our multi-tenant schema design guide.

If you're rolling this out, start with one enterprise customer's SAML connection end to end before generalizing the Action and token bridge for others; it's much easier to debug a missing claim with one Organization in play than five. Bookmark this one, you'll likely be back here the first time a prospect's IT team asks about SCIM.