Design wechseln

Supabase Auth – Tiefenkonfiguration: OAuth, SSO und Berechtigungssteuerung

Easton editorial illustration: solo-founder business system console

An einem Nachmittag kam Sales zu mir: „Der Kunde will Login über Okta – SSO muss in zwei Wochen live sein.“ Ich war kurz sprachlos – meine App kannte nur E-Mail-Registrierung und Google OAuth. SAML? Unbekanntes Terrain.

In B2B-SaaS ist das Alltag. Enterprise-Kunden akzeptieren keine separaten Konten für jeden Mitarbeiter. Sie haben zentrale IdPs (Okta, Azure AD, Google Workspace): ein Klick zum Login, und beim Austritt wird der Zugriff überall entzogen.

Dieser Artikel ist für genau dieses Szenario. Wir starten mit OAuth-Social-Login, gehen zu SAML-SSO über und schließen mit Row Level Security (RLS) für Mandantenisolation ab – ein durchgängiges Auth- und Autorisierungskonzept von Consumer bis Enterprise.

1. OAuth – mehrere Provider in der Praxis

OAuth-Social-Login ist der Einstieg für die meisten Apps. Nutzer wollen keine Passwörter merken, Sie wollen keine Passwortspeicherung – Google oder GitHub übernehmen das. Beide Seiten profitieren.

Supabase unterstützt viele OAuth-Provider: Google, GitHub, Apple, Facebook, Discord, Twitter … In Produktion dominieren Google und GitHub; für iOS-Apps ist Apple Pflicht (App-Store-Richtlinien). Wir beginnen mit Google.

Google OAuth konfigurieren

Öffnen Sie die Google Cloud Console – die Menüs wirken unübersichtlich. Suchen Sie direkt nach „OAuth Client ID“.

Wählen Sie Web application und tragen Sie die Authorized redirect URI ein:

https://<ihre-projekt-ref>.supabase.co/auth/v1/callback

Diese URL empfängt den OAuth-Callback von Supabase Auth. Die Projekt-Ref steht oben links im Supabase Dashboard, z. B. abcdefghijklmnop.

Mit Client ID und Client Secret gehen Sie ins Supabase Dashboard unter Authentication > Providers, aktivieren Google und tragen die Werte ein.

Der Code-Aufruf ist schlank:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://abcdefghijklmnop.supabase.co',
  'your-anon-key'
)

// OAuth-Login starten
const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: 'https://your-app.com/auth/callback',
    scopes: 'email profile'
  }
})

if (error) {
  console.error('Anmeldung fehlgeschlagen:', error.message)
  return
}

// data.url ist die Weiterleitungs-URL – window.location.href = data.url

redirectTo ist die URL Ihrer App für das Login-Ergebnis; Supabase übergibt code und state. Auf dieser Seite rufen Sie supabase.auth.exchangeCodeForSession() auf:

// Auf der Seite /auth/callback
const { error } = await supabase.auth.exchangeCodeForSession()
if (!error) {
  // Erfolg – Weiterleitung zur Startseite
  window.location.href = '/'
}

GitHub OAuth konfigurieren

Ähnlicher Ablauf unter Settings > Developer settings > OAuth Apps. Authorization callback URL wieder https://<projekt-ref>.supabase.co/auth/v1/callback.

Unterschied: GitHub hat keine Scopes-Oberfläche – Scopes setzen Sie im Code:

await supabase.auth.signInWithOAuth({
  provider: 'github',
  options: {
    redirectTo: 'https://your-app.com/auth/callback',
    scopes: 'repo user'  // repo für Zugriff auf private Repos
  }
})

Für reines Login reichen die Standard-Scopes. Für GitHub-Daten (z. B. Repo-Liste) brauchen Sie repo.

Apple OAuth konfigurieren

Apple ist am aufwendigsten: Services ID im Apple Developer Portal, privater Schlüssel (.p8, nur einmal downloadbar – Verlust bedeutet Neuerzeugung).

Wichtige Parameter:

  • Services ID: entspricht der Client ID
  • Team ID: unter Membership
  • Key ID: ID des Schlüssels
  • Private Key: Inhalt der .p8-Datei

Im Supabase Dashboard den Private Key vollständig einfügen (inkl. BEGIN/END-Zeilen).

Aufruf wie bei Google/GitHub:

await supabase.auth.signInWithOAuth({
  provider: 'apple',
  options: {
    redirectTo: 'https://your-app.com/auth/callback'
  }
})

Für iOS gibt es eine Alternative: natives Sign in with Apple, dann Token an Supabase:

// identity_token von Apple im Frontend
const { data, error } = await supabase.auth.signInWithIdTokenCredentials({
  provider: 'apple',
  token: identityToken
})

Praktisch, wenn die native Apple-Anmeldung bereits integriert ist.

2. SAML-SSO für Unternehmen

Zurück zum Anfang: Der Kunde will Okta. Dafür reicht OAuth nicht – Sie brauchen SAML 2.0 SSO.

SAML funktioniert anders als OAuth: Nicht der Nutzer autorisiert Ihre App, sondern der Identity Provider (IdP) sendet Identitätsdaten an Ihre Anwendung (Service Provider, SP). Für Unternehmen ist das kontrollierbarer – Konto im IdP deaktiviert, Zugriff in allen angebundenen Apps weg.

Vorbereitung

Besorgen Sie die IdP-Metadata vom Kunden – Zertifikat, Endpunkte usw. Okta, Azure AD und Google Workspace exportieren sie.

Wichtige Supabase-URLs:

EntityID (SP-Kennung):
https://<projekt-ref>.supabase.co/auth/v1/sso/saml/metadata

ACS URL (empfängt SAML Response):
https://<projekt-ref>.supabase.co/auth/v1/sso/saml/acs

Metadata URL (Download):
https://<projekt-ref>.supabase.co/auth/v1/sso/saml/metadata?download=true

Diese URLs geben Sie dem IT-Admin für die SAML-App in Okta/Azure AD.

SSO per Supabase CLI

Das Dashboard unterstützt SAML inzwischen auch – für wiederholtes Feintuning bevorzuge ich die CLI.

# SAML-Verbindung hinzufügen
supabase sso add --type saml --project-ref <projekt-ref> \
  --metadata-url 'https://company.okta.com/app/exk123/saml/samlmetadata' \
  --domains company.com

--domains ist entscheidend: Nutzer mit @company.com nutzen diese SAML-Verbindung.

Alternativ --metadata-file statt URL:

supabase sso add --type saml --project-ref <projekt-ref> \
  --metadata-file ./okta-metadata.xml \
  --domains company.com

Die Ausgabe enthält eine sso_provider_id (z. B. abc123def456) – wichtig für RLS-Isolation.

Attribute Mapping

Die SAML Response liefert E-Mail, Name, Abteilung – Feldnamen variieren. Mapping definieren Sie in JSON:

{
  "keys": {
    "email": {
      "name": "email",
      "names": ["EmailAddress", "email", "mail"],
      "required": true
    },
    "first_name": {
      "name": "first_name",
      "names": ["FirstName", "givenName", "first_name"]
    },
    "last_name": {
      "name": "last_name",
      "names": ["LastName", "surname", "last_name"]
    }
  }
}

Anwenden mit supabase sso update:

supabase sso update <sso_provider_id> \
  --project-ref <projekt-ref> \
  --attribute-mapping-file ./mapping.json

Multi-Tenant-SSO

Mehrere SAML-Verbindungen pro Projekt sind möglich – z. B. Acme Corp (Okta) und Globex Inc (Azure AD):

# SSO für Acme Corp
supabase sso add --type saml --project-ref <projekt-ref> \
  --metadata-url 'https://acme.okta.com/.../metadata' \
  --domains acme.com

# Rückgabe sso_provider_id: provider_abc

# SSO für Globex Inc
supabase sso add --type saml --project-ref <projekt-ref> \
  --metadata-url 'https://globex.azure.com/.../metadata' \
  --domains globex.com

# Rückgabe sso_provider_id: provider_def

Acme-Nutzer laufen über provider_abc, Globex über provider_def – getrennte Nutzerdaten pro Verbindung.

Login-Erlebnis

Nach der Konfiguration:

  1. Nutzer gibt E-Mail ein (z. B. [email protected])
  2. Supabase erkennt SSO für acme.com
  3. Weiterleitung zur Okta-Anmeldung
  4. Anmeldung bei Okta (oft schon eingeloggt)
  5. SAML Response an Supabase
  6. Session wird erstellt, Rücksprung in Ihre App

Code zum Starten:

// Nach E-Mail-Eingabe
const { data, error } = await supabase.auth.signInWithSSO({
  domain: 'acme.com'
})

if (data?.url) {
  window.location.href = data.url
}

Oder direkt mit sso_provider_id:

const { data, error } = await supabase.auth.signInWithSSO({
  providerId: 'provider_abc'
})

Besonderheiten von SSO-Nutzern

SSO-Nutzer unterscheiden sich von „normalen“ Konten:

  • E-Mail vom IdP: nicht in der App änderbar
  • Kein Passwort in Supabase: Verifikation liegt beim IdP
  • E-Mail als verifiziert: IdP hat bereits geprüft

E-Mail-Änderungen laufen über den IT-Admin in Okta/Azure AD.

3. Row Level Security – fortgeschritten

Authentifizierung klärt „wer ist der Nutzer?“, Autorisierung „was darf er?“.

Klassisch prüft jede API die Berechtigung – repetitiv, fehleranfällig, teuer in der Datenbank.

PostgreSQL Row Level Security (RLS) verlagert die Prüfung in die Datenbank. Jede Abfrage wird automatisch gefiltert.

Standard nach Aktivierung

Häufige Falle: Nach ENABLE ROW LEVEL SECURITY ist alles gesperrt.

-- RLS aktivieren
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Jede Abfrage liefert nichts (auch für Admins)
SELECT * FROM posts;  -- 0 Zeilen

Erst Policies erlauben Zugriff.

USING und WITH CHECK

USING: Darf der Nutzer die Zeile „sehen“ oder ansprechen? Für SELECT, UPDATE, DELETE.

WITH CHECK: Darf er so schreiben? Für INSERT, UPDATE – der neue Zustand muss die Bedingung erfüllen.

-- Nutzer verwalten nur eigene Posts
CREATE POLICY "Users manage own posts" ON posts
FOR ALL TO authenticated
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);

-- Nur Lesen
CREATE POLICY "Users view own posts" ON posts
FOR SELECT TO authenticated
USING (auth.uid() = author_id);

-- Nur Einfügen
CREATE POLICY "Users insert posts" ON posts
FOR INSERT TO authenticated
WITH CHECK (auth.uid() = author_id);

auth.uid() ist die UUID des eingeloggten Nutzers; TO authenticated schränkt auf angemeldete Nutzer ein.

RESTRICTIVE vs PERMISSIVE

Mehrere Policies pro Tabelle sind möglich. Standard: PERMISSIVE – eine erfüllte Policy reicht.

-- Policy 1: eigene Daten
CREATE POLICY "Own data" ON posts
FOR SELECT USING (auth.uid() = author_id);

-- Policy 2: öffentliche Posts
CREATE POLICY "Public posts" ON posts
FOR SELECT USING (is_public = true);

-- Zwei PERMISSIVE Policies: eine reicht

RESTRICTIVE Policies müssen zusätzlich erfüllt sein – oft als globaler Filter:

-- Globaler Mandantenfilter
CREATE POLICY "Tenant isolation" ON posts
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
  SELECT tenant_id FROM users WHERE id = auth.uid()
));

-- PERMISSIVE für konkrete Aktionen
CREATE POLICY "Authors edit own posts" ON posts
FOR UPDATE USING (auth.uid() = author_id);

Ergebnis: richtiger Mandant (RESTRICTIVE) und Autor beim Bearbeiten (PERMISSIVE).

Vollständiges Multi-Tenant-Muster

SaaS mit einem Mandanten pro Kunde – tenant_id in Nutzer- und Geschäftstabellen:

-- Nutzertabelle
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL,
  email TEXT,
  sso_provider_id TEXT  -- nur bei SSO
);

-- Geschäftstabelle
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT,
  created_by UUID REFERENCES users(id)
);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Globaler Mandantenfilter (RESTRICTIVE)
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
  SELECT tenant_id FROM users WHERE id = auth.uid()
));

-- Operationen (PERMISSIVE)
CREATE POLICY "Tenant users can view" ON projects
FOR SELECT TO authenticated
USING (true);

CREATE POLICY "Project creators can edit" ON projects
FOR UPDATE TO authenticated
USING (created_by = auth.uid());

Selbst SELECT * FROM projects liefert nur Daten des aktuellen Mandanten.

SSO und Mandantenisolation

Für SSO-Nutzer hilft sso_provider_id. Im JWT steht die Login-Methode:

-- RLS für SSO-Nutzer
CREATE POLICY "SSO tenant isolation" ON organization_settings
AS RESTRICTIVE TO authenticated
USING (sso_provider_id = (
  SELECT auth.jwt()#>>'{amr,0,provider}'
));

auth.jwt()#>>'{amr,0,provider}' liefert bei SSO die sso_provider_id.

Performance

RLS wirkt wie implizites WHERE – oft mit Subqueries. Optimierung:

1. Indizes auf Policy-Feldern

CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);

2. Subqueries in Policies vermeiden

Pro Zeile teuer. Besser tenant_id im JWT:

-- Ungeeignet: Subquery
USING (tenant_id = (SELECT tenant_id FROM users WHERE id = auth.uid()))

-- Empfohlen: JWT
USING (tenant_id = (auth.jwt()->>'tenant_id')::uuid)

Dafür der Custom Access Token Hook (nächstes Kapitel).

3. SECURITY DEFINER-Funktionen

CREATE FUNCTION current_tenant_id() RETURNS UUID
LANGUAGE SQL STABLE SECURITY DEFINER AS $$
  SELECT tenant_id FROM users WHERE id = auth.uid();
$$;

CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE USING (tenant_id = current_tenant_id());

STABLE erlaubt PostgreSQL, die Funktion pro Transaktion einmal auszuführen.

4. Custom Claims und RBAC

Standard-JWT enthält Nutzer-ID, E-Mail, Rolle (authenticated/anon). Oft brauchen Sie mehr: Rolle (admin/moderator), Mandanten-ID, Berechtigungsliste.

Der Custom Access Token Hook passt das JWT vor der Ausstellung an.

Typische Einsatzfälle

  1. RBAC: Rolle im JWT, Policy prüft darüber
  2. Multi-Tenant: tenant_id im JWT statt Subquery
  3. Kleineres JWT: unnötige Felder entfernen – relevant bei SSR und großen Cookies

Supabase-JWTs sind standardmäßig umfangreich (session_id, aal, amr …). Bei vielen SSR-Seiten summiert sich das im Cookie.

Hook implementieren

PL/pgSQL-Funktion vor JWT-Erzeugung:

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
  claims jsonb;
  user_role text;
  tenant_id uuid;
BEGIN
  claims := event->'claims';

  SELECT role INTO user_role
  FROM public.user_roles
  WHERE user_id = (event->>'user_id')::uuid;

  IF user_role IS NOT NULL THEN
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  END IF;

  SELECT tenant_id INTO tenant_id
  FROM public.users
  WHERE id = (event->>'user_id')::uuid;

  IF tenant_id IS NOT NULL THEN
    claims := jsonb_set(claims, '{tenant_id}', to_jsonb(tenant_id));
  END IF;

  RETURN jsonb_build_object('claims', claims);
END;
$$;

GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb)
TO supabase_auth_admin;

-- Dashboard: Authentication > Hooks > Custom Access Token

event enthält u. a. user_id, claims, authentication_method. Der zurückgegebene claims-Block landet im JWT.

RBAC-Schema

CREATE TYPE app_role AS ENUM ('admin', 'moderator', 'user');

CREATE TYPE app_permission AS ENUM (
  'posts.delete',
  'posts.pin',
  'users.manage',
  'settings.edit'
);

CREATE TABLE public.user_roles (
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  role app_role NOT NULL,
  PRIMARY KEY (user_id, role)
);

CREATE TABLE public.role_permissions (
  role app_role NOT NULL,
  permission app_permission NOT NULL,
  PRIMARY KEY (role, permission)
);

INSERT INTO role_permissions (role, permission) VALUES
  ('admin', 'posts.delete'),
  ('admin', 'posts.pin'),
  ('admin', 'users.manage'),
  ('admin', 'settings.edit'),
  ('moderator', 'posts.delete'),
  ('moderator', 'posts.pin');

authorize()-Funktion

CREATE OR REPLACE FUNCTION public.authorize(
  requested_permission app_permission
)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
AS $$
DECLARE
  user_role app_role;
BEGIN
  SELECT (auth.jwt()->>'user_role')::app_role
  INTO user_role;

  IF user_role IS NULL THEN
    RETURN false;
  END IF;

  RETURN EXISTS (
    SELECT 1 FROM public.role_permissions
    WHERE role = user_role
    AND permission = requested_permission
  );
END;
$$;

GRANT EXECUTE ON FUNCTION public.authorize(app_permission)
TO authenticated;

authorize() in Policies

CREATE POLICY "Role-based delete" ON posts
FOR DELETE TO authenticated
USING (
  authorize('posts.delete') OR auth.uid() = author_id
);

CREATE POLICY "Admin pin posts" ON posts
FOR UPDATE TO authenticated
USING (
  NOT is_pinned OR authorize('posts.pin')
);

Erste Policy: Löschen mit posts.delete oder als Autor. Zweite: is_pinned = true nur mit posts.pin.

Custom Claims im Frontend

import { jwtDecode } from 'jwt-decode'

const { data: { session } } = await supabase.auth.getSession()

if (session) {
  const decoded = jwtDecode(session.access_token)
  console.log('Rolle:', decoded.user_role)
  console.log('Mandant:', decoded.tenant_id)
}

jwtDecode dekodiert nur – die Signaturprüfung erfolgt serverseitig bei Supabase.

5. Enterprise-SaaS – Gesamtszenario

OAuth, SSO, RLS und Custom Claims zusammen: Zwei Nutzergruppen:

  1. Enterprise: SSO (Okta/Azure AD), Daten einem Mandanten zugeordnet
  2. Privat: Google/GitHub OAuth, kein Mandant, nur öffentliche Ressourcen

Enterprise-Daten bleiben für OAuth-Nutzer unsichtbar.

Nutzertabelle

CREATE TABLE public.users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT NOT NULL,

  auth_type TEXT NOT NULL DEFAULT 'oauth',  -- 'oauth' oder 'sso'

  sso_provider_id TEXT,
  tenant_id UUID,

  full_name TEXT,
  avatar_url TEXT,

  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE public.tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  sso_provider_id TEXT NOT NULL UNIQUE,
  plan_type TEXT NOT NULL DEFAULT 'team',
  created_at TIMESTAMPTZ DEFAULT now()
);

Einheitlicher Custom Access Token Hook

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
  claims jsonb;
  user_record RECORD;
BEGIN
  claims := event->'claims';

  SELECT auth_type, sso_provider_id, tenant_id, role
  INTO user_record
  FROM public.users
  WHERE id = (event->>'user_id')::uuid;

  IF user_record IS NULL THEN
    RETURN jsonb_build_object('claims', claims);
  END IF;

  claims := jsonb_set(claims, '{auth_type}', to_jsonb(user_record.auth_type));

  IF user_record.auth_type = 'sso' THEN
    IF user_record.tenant_id IS NOT NULL THEN
      claims := jsonb_set(claims, '{tenant_id}', to_jsonb(user_record.tenant_id));
    END IF;
    IF user_record.sso_provider_id IS NOT NULL THEN
      claims := jsonb_set(claims, '{sso_provider_id}', to_jsonb(user_record.sso_provider_id));
    END IF;
  END IF;

  IF user_record.role IS NOT NULL THEN
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_record.role));
  END IF;

  RETURN jsonb_build_object('claims', claims);
END;
$$;

Nutzeranlage nach Login

OAuth und SSO legen auth.users an – danach synchronisieren Sie public.users:

const { data: { user } } = await supabase.auth.getUser()

if (user) {
  const { data: existingUser } = await supabase
    .from('users')
    .select('id')
    .eq('id', user.id)
    .single()

  if (!existingUser) {
    const authType = user.app_metadata?.provider || 'oauth'

    await supabase.from('users').insert({
      id: user.id,
      email: user.email,
      auth_type: authType.startsWith('sso') ? 'sso' : 'oauth',
      sso_provider_id: user.app_metadata?.sso_provider_id,
      tenant_id: null,
      full_name: user.user_metadata?.full_name,
      avatar_url: user.user_metadata?.avatar_url
    })
  }
}

Einheitliche RLS für projects

CREATE TABLE public.projects (
  id UUID PRIMARY KEY,
  tenant_id UUID REFERENCES tenants(id),
  name TEXT NOT NULL,
  is_public BOOLEAN DEFAULT false,
  created_by UUID REFERENCES users(id)
);

ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Tenant or public access" ON public.projects
AS RESTRICTIVE TO authenticated
USING (
  (auth.jwt()->>'auth_type' = 'sso'
   AND tenant_id = (auth.jwt()->>'tenant_id')::uuid)
  OR
  (auth.jwt()->>'auth_type' = 'oauth' AND is_public = true)
);

CREATE POLICY "Users can view" ON public.projects
FOR SELECT TO authenticated
USING (true);

CREATE POLICY "Tenant users can create" ON public.projects
FOR INSERT TO authenticated
WITH CHECK (
  auth.jwt()->>'auth_type' = 'sso'
  AND tenant_id = (auth.jwt()->>'tenant_id')::uuid
);

CREATE POLICY "Creators or admins can edit" ON public.projects
FOR UPDATE TO authenticated
USING (
  created_by = auth.uid()
  OR authorize('projects.edit')
);

Logik: SSO sieht nur den eigenen Mandanten; OAuth nur is_public; Erstellen nur SSO mit Mandant; Bearbeiten als Ersteller oder mit projects.edit.

Mandantenzuweisung durch Admin

Nach erstem SSO-Login ist tenant_id oft null:

UPDATE public.users
SET tenant_id = '<mandanten-uuid>'
WHERE id = '<nutzer-uuid>';

INSERT INTO public.user_roles (user_id, role)
VALUES ('<nutzer-uuid>', 'admin');

Per Admin-UI oder Auth Hook automatisch aus sso_provider_id ableiten.

Ablaufübersicht

Nutzer meldet sich an

   ├─ OAuth
   │    │
   │    ├─ Google/GitHub Callback
   │    ├─ Supabase legt auth.users an
   │    ├─ App legt public.users an (auth_type='oauth')
   │    └─ JWT: { auth_type: 'oauth' }
   │    │
   │    └─ RLS: nur is_public=true

   └─ SSO

        ├─ Okta/Azure AD SAML Callback
        ├─ auth.users mit sso_provider_id
        ├─ public.users (auth_type='sso')
        ├─ Admin setzt tenant_id
        ├─ Hook schreibt tenant_id ins JWT
        └─ JWT: { auth_type: 'sso', tenant_id: '...' }

        └─ RLS: nur passender tenant_id

Von OAuth über SAML-SSO bis RLS und Custom Claims – ein durchgängiges Modell für Consumer- bis Enterprise-Szenarien.

Entscheidungshilfen:

Überwiegend Privatnutzer: Google/GitHub-OAuth reicht – einfach, vertraut, günstig in der Wartung. RLS auf „eigene Daten“ genügt oft.

B2B-SaaS: SSO ist Pflicht. Okta, Azure AD und Google Workspace einplanen, Multi-Tenant von Anfang an mitdenken.

Komplexe Enterprise-Apps: RBAC plus RLS – Rollen-/Rechtetabellen, Custom Claims, authorize() – für feine Regeln wie „löschen ja, anpinnen nein“ oder „Mandant darf bearbeiten, nicht löschen“.

Pragmatischer Weg: Mit OAuth starten, Flow stabilisieren. Bei Enterprise-Nachfrage SSO und RBAC schichtweise ergänzen – Supabase erlaubt schrittweises Ausbauen ohne Big-Bang am ersten Tag.

Supabase Auth – Enterprise-Konfiguration von OAuth bis RLS

Schritt für Schritt von OAuth-Social-Login über SAML-SSO bis zur RLS-Mandantenisolation

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: OAuth-Provider konfigurieren (Google/GitHub/Apple)

    1. OAuth-App in der Provider-Konsole anlegen (Google Cloud Console / GitHub Settings)
    2. Callback-URL: https://<projekt-ref>.supabase.co/auth/v1/callback
    3. Provider im Supabase Dashboard aktivieren, Client ID und Secret eintragen
    4. signInWithOAuth() mit scopes und redirectTo aufrufen
  2. 2

    Step 2: SAML-SSO für Unternehmen einrichten

    1. Metadata-Datei oder -URL vom IdP (Okta/Azure AD) beschaffen
    2. SSO-Verbindung per CLI: supabase sso add --type saml --domains company.com
    3. Attribute Mapping für E-Mail, Name usw. konfigurieren
    4. Login testen: E-Mail eingeben, automatische Weiterleitung zum IdP
  3. 3

    Step 3: RLS-Mandantenisolation umsetzen

    1. tenant_id in Geschäftstabellen ergänzen
    2. RLS aktivieren: ALTER TABLE projects ENABLE ROW LEVEL SECURITY
    3. RESTRICTIVE Policy als globalen Mandantenfilter anlegen
    4. PERMISSIVE Policies für SELECT/INSERT/UPDATE
    5. Index auf tenant_id für Performance
  4. 4

    Step 4: Custom Access Token Hook einrichten

    1. Funktion public.custom_access_token_hook() anlegen
    2. tenant_id, user_role usw. als Custom Claims setzen
    3. supabase_auth_admin zur Ausführung berechtigen
    4. Hook im Dashboard aktivieren (Authentication > Hooks)
    5. Frontend liest Claims per jwtDecode()
  5. 5

    Step 5: RBAC-Berechtigungssteuerung implementieren

    1. Tabellen user_roles und role_permissions anlegen
    2. ENUM-Typen app_role und app_permission definieren
    3. authorize()-Funktion für Berechtigungsprüfung
    4. In RLS Policies authorize('permission.name') nutzen
    5. UI anhand von user_role im JWT ein-/ausblenden

FAQ

Was ist der Unterschied zwischen OAuth und SAML SSO – was soll ich wählen?
OAuth eignet sich für Consumer-Apps: Nutzer melden sich mit Google/GitHub an, Konfiguration ist einfach, Wartung günstig. SAML SSO passt zu B2B: Mitarbeiter nutzen das Firmenkonto (Okta/Azure AD), Konten werden zentral verwaltet, Zugriff endet beim Austritt automatisch.

Für B2B-SaaS ist SSO Pflicht; für reine Privatprodukte reicht OAuth.
Nach Aktivierung von RLS liefert die Abfrage keine Zeilen – was tun?
Das ist Standardverhalten: Mit RLS sind alle Zugriffe zunächst verboten. Erst Policies erlauben Zugriff. Beispiel: CREATE POLICY "Users view own posts" ON posts FOR SELECT TO authenticated USING (auth.uid() = author_id);
RLS-Policies sind langsam – wie optimieren?
Drei Hebel:

• Index auf Policy-Feldern: CREATE INDEX idx_tenant ON projects(tenant_id);
• Keine Subqueries in Policies: tenant_id per Custom Access Token Hook ins JWT, Auslesen mit auth.jwt()->>'tenant_id'
• Komplexe Logik in SECURITY DEFINER-Funktion auslagern – PostgreSQL führt sie einmal aus
Wie speichere ich Custom Fields wie tenant_id oder user_role im JWT?
Über den Custom Access Token Hook von Supabase:

1. PL/pgSQL-Funktion custom_access_token_hook(event jsonb)
2. Claims mit jsonb_set() erweitern
3. Hook im Dashboard aktivieren (Authentication > Hooks > Custom Access Token)
4. Frontend liest Custom Claims mit jwtDecode()
Wie trenne ich Enterprise- und Privatnutzer in einem Multi-Tenant-SaaS?
Kernidee: auth_type ('oauth' oder 'sso') und tenant_id im JWT, RLS filtert danach:

• SSO-Nutzer: nur Daten des passenden tenant_id
• OAuth-Nutzer: nur Daten mit is_public=true

RESTRICTIVE Policy als globaler Filter, PERMISSIVE Policies für konkrete Operationen.
Unterstützt Supabase Apple Sign In – ist die Einrichtung aufwendig?
Ja, aber aufwendiger als Google/GitHub:

1. Services ID im Apple Developer Portal
2. Privaten Schlüssel erzeugen (.p8, nur einmal downloadbar)
3. Team ID, Key ID, Services ID und Schlüssel im Supabase Dashboard eintragen

iOS-Apps können die native Sign in with Apple API nutzen und identity_token an signInWithIdTokenCredentials() übergeben.
Der Kunde verlangt SSO, aber der IdP-Typ ist unbekannt – was tun?
IT-Admin um IdP-Metadata (Datei oder URL) bitten – Okta, Azure AD und Google Workspace exportieren das. Mit supabase sso add --metadata-file die Verbindung anlegen; Supabase parst Endpunkte und Zertifikate automatisch.

13 Min. Lesezeit · Veröffentlicht am: 21. Apr. 2026 · Aktualisiert am: 14. Juli 2026

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog