Gatewaze uses an adapter pattern for authentication, supporting both Supabase Auth and external OIDC providers. This guide covers setting up each provider, the permissions system, and integrating external auth with Supabase's Row Level Security.
- Overview
- Supabase Auth (Default)
- OIDC (External Identity Provider)
- Permissions System
- RLS and External Auth Integration
Gatewaze separates authentication (who you are) from authorization (what you can do). The auth adapter handles authentication, while the permissions system handles authorization.
User
|
v
Auth Adapter (Supabase Auth or OIDC)
|
| authenticates, returns user identity
v
Admin Profile Lookup (admin_profiles table)
|
| maps identity to admin role + permissions
v
Permission Check
|
| role + feature permissions = access decision
v
Authorized Action
The AUTH_PROVIDER environment variable (or auth.provider in gatewaze.config.ts) controls which adapter is active. Changing the provider does not affect existing data -- admin profiles and permissions are stored independently of the auth provider.
Supabase Auth is the default authentication provider. It handles user accounts, session management, and email delivery for authentication flows.
The default flow uses magic links (passwordless email login):
- User enters their email address on the admin login page.
- Supabase Auth sends a magic link to that email address.
- User clicks the link, which contains a one-time token.
- The token is exchanged for a session JWT.
- The JWT is used for all subsequent API requests.
- The admin app looks up the user's
admin_profilesrecord by their Supabaseuser_idto determine their role and permissions.
Magic links require a working email delivery mechanism. In development, Supabase captures emails in its built-in Inbucket mail server (accessible at http://localhost:54324). For production, configure one of the following:
Set SMTP variables in your .env file. These are used by the Supabase GoTrue service directly:
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=SG.your-sendgrid-key
SMTP_ADMIN_EMAIL=noreply@yourdomain.comIf you are using Supabase Cloud, configure email in your project's dashboard under Authentication > Email Templates. Supabase Cloud provides a built-in email service for low-volume sends and supports custom SMTP for higher volumes.
To enable password-based login alongside magic links:
-
Enable email sign-up in your Supabase configuration:
ENABLE_EMAIL_SIGNUP=true
-
Optionally enable auto-confirm to skip email verification (not recommended for production):
ENABLE_EMAIL_AUTOCONFIRM=false
-
Update the admin login page to include a password field. The Supabase client library supports both flows:
// Magic link const { error } = await supabase.auth.signInWithOtp({ email: 'user@example.com', }); // Password const { error } = await supabase.auth.signInWithPassword({ email: 'user@example.com', password: 'secure-password', });
-
Create user accounts with passwords via the Supabase dashboard, Supabase Studio, or programmatically using the admin API:
const { data, error } = await supabaseAdmin.auth.admin.createUser({ email: 'user@example.com', password: 'secure-password', email_confirm: true, });
Admin users are tracked in the admin_profiles table:
CREATE TABLE public.admin_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
email text UNIQUE NOT NULL,
name text,
role text NOT NULL DEFAULT 'editor'
CHECK (role IN ('super_admin', 'admin', 'editor')),
avatar_url text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);The user_id column links the admin profile to the Supabase Auth user. When a user signs in, the application looks up their profile by user_id to determine their role.
-
Via the admin UI: Navigate to the user management section as a super_admin.
-
Via Supabase Studio: Insert a row into
admin_profileswith the user's email. Setuser_idafter they sign in for the first time. -
Via SQL:
INSERT INTO admin_profiles (email, name, role) VALUES ('newadmin@example.com', 'New Admin', 'admin');
Set is_active = false to revoke access without deleting the profile:
UPDATE admin_profiles SET is_active = false WHERE email = 'user@example.com';For organizations that manage identity centrally, Gatewaze supports any OpenID Connect (OIDC) provider.
Keycloak is a popular open-source identity provider.
Create a new realm (e.g., gatewaze) or use an existing one.
In your Keycloak realm, create a new client:
| Setting | Value |
|---|---|
| Client ID | gatewaze-admin |
| Client Protocol | openid-connect |
| Access Type | confidential |
| Valid Redirect URIs | http://localhost:5173/auth/callback (dev) or your production URL |
| Web Origins | http://localhost:5173 or your production URL |
Go to the Credentials tab and copy the client secret.
Create the following realm roles (or client roles):
gatewaze-super-admingatewaze-admingatewaze-editor
Assign these roles to your users.
Update your .env:
AUTH_PROVIDER=oidc
OIDC_ISSUER_URL=https://keycloak.yourdomain.com/realms/gatewaze
OIDC_CLIENT_ID=gatewaze-admin
OIDC_CLIENT_SECRET=your-client-secretUpdate gatewaze.config.ts:
auth: {
provider: 'oidc',
oidc: {
issuerUrl: process.env.OIDC_ISSUER_URL,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scopes: ['openid', 'profile', 'email'],
roleMapping: {
claimPath: 'realm_access.roles',
superAdmin: 'gatewaze-super-admin',
admin: 'gatewaze-admin',
editor: 'gatewaze-editor',
},
},
},Auth0 is a popular managed identity platform.
In the Auth0 dashboard, create a new Regular Web Application.
| Setting | Value |
|---|---|
| Name | Gatewaze Admin |
| Allowed Callback URLs | http://localhost:5173/auth/callback |
| Allowed Logout URLs | http://localhost:5173 |
| Allowed Web Origins | http://localhost:5173 |
Copy the Domain, Client ID, and Client Secret from the application settings.
Navigate to User Management > Roles and create:
gatewaze-super-admingatewaze-admingatewaze-editor
Create a post-login Action or Rule that adds roles to the token:
// Auth0 Action: Add roles to token
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://gatewaze.io';
const roles = event.authorization?.roles || [];
api.idToken.setCustomClaim(`${namespace}/roles`, roles);
api.accessToken.setCustomClaim(`${namespace}/roles`, roles);
};AUTH_PROVIDER=oidc
OIDC_ISSUER_URL=https://your-tenant.auth0.com/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secretauth: {
provider: 'oidc',
oidc: {
issuerUrl: process.env.OIDC_ISSUER_URL,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scopes: ['openid', 'profile', 'email'],
roleMapping: {
claimPath: 'https://gatewaze.io/roles',
superAdmin: 'gatewaze-super-admin',
admin: 'gatewaze-admin',
editor: 'gatewaze-editor',
},
},
},For organizations using Google Workspace, you can use Google as an OIDC provider.
In the Google Cloud Console:
- Go to APIs & Services > Credentials.
- Click Create Credentials > OAuth client ID.
- Select Web application.
- Set the authorized redirect URI to
http://localhost:5173/auth/callback(or your production URL). - Note the Client ID and Client Secret.
AUTH_PROVIDER=oidc
OIDC_ISSUER_URL=https://accounts.google.com
OIDC_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
OIDC_CLIENT_SECRET=your-google-client-secretauth: {
provider: 'oidc',
oidc: {
issuerUrl: process.env.OIDC_ISSUER_URL,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scopes: ['openid', 'profile', 'email'],
},
},Google does not provide custom roles in tokens by default. When using Google as the OIDC provider, admin roles are managed entirely through the admin_profiles table. Users are matched by email address after authentication.
| Config Path | Type | Required | Description |
|---|---|---|---|
auth.oidc.issuerUrl |
string |
Yes | OIDC provider's issuer URL (must serve /.well-known/openid-configuration) |
auth.oidc.clientId |
string |
Yes | OAuth 2.0 client ID |
auth.oidc.clientSecret |
string |
Yes | OAuth 2.0 client secret |
auth.oidc.scopes |
string[] |
No | Additional scopes to request (default: openid profile email) |
auth.oidc.roleMapping.claimPath |
string |
No | Dot-notation path to the roles array in the ID token |
auth.oidc.roleMapping.superAdmin |
string |
No | Claim value that maps to super_admin role |
auth.oidc.roleMapping.admin |
string |
No | Claim value that maps to admin role |
auth.oidc.roleMapping.editor |
string |
No | Claim value that maps to editor role |
When roleMapping is configured, Gatewaze extracts roles from the ID token using the claimPath and maps them to internal admin roles.
Claim path resolution example:
For a token with the following structure:
{
"sub": "user-123",
"email": "admin@example.com",
"realm_access": {
"roles": ["gatewaze-admin", "other-role"]
}
}With claimPath: 'realm_access.roles', Gatewaze reads the array ["gatewaze-admin", "other-role"] and checks for matches against superAdmin, admin, and editor values. The highest-privilege match wins:
super_admin(highest)admineditor(lowest)
If no role mapping is configured or no matching claims are found, the user's role defaults to the value stored in their admin_profiles record, or editor if no profile exists.
Beyond the three admin roles, Gatewaze provides a fine-grained feature-based permission system.
| Role | Description |
|---|---|
super_admin |
Full access to all features, including user management, settings, and permissions administration |
admin |
Access to most features. Cannot manage other admin users or change system settings |
editor |
Limited access. Can create and edit content (events, speakers, etc.) but cannot access administrative functions |
Roles are stored in the admin_profiles.role column.
For more granular control, individual features can be granted or revoked per admin:
-- admin_permissions table
CREATE TABLE public.admin_permissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid NOT NULL REFERENCES public.admin_profiles(id) ON DELETE CASCADE,
feature text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
granted_by uuid REFERENCES public.admin_profiles(id) ON DELETE SET NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
UNIQUE (admin_id, feature)
);Example features:
| Feature | Description |
|---|---|
events.create |
Create new events |
events.edit |
Edit existing events |
events.delete |
Delete events |
events.publish |
Publish draft events |
speakers.manage |
Manage speaker profiles |
members.manage |
Manage member directory |
calendars.manage |
Create and configure calendars |
registrations.manage |
Manage event registrations |
email.send |
Send bulk or transactional email |
settings.manage |
Modify system settings |
users.manage |
Create and manage admin users |
Feature permissions are additive. An admin with the editor role and the events.publish permission can publish events even though editor does not normally have that capability.
Permission groups bundle multiple features for easy assignment:
-- admin_permission_groups table
CREATE TABLE public.admin_permission_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
features text[] NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- admin_permission_group_assignments table
CREATE TABLE public.admin_permission_group_assignments (
admin_id uuid NOT NULL REFERENCES public.admin_profiles(id) ON DELETE CASCADE,
group_id uuid NOT NULL REFERENCES public.admin_permission_groups(id) ON DELETE CASCADE,
assigned_by uuid REFERENCES public.admin_profiles(id) ON DELETE SET NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (admin_id, group_id)
);Example groups:
-- Content Manager group
INSERT INTO admin_permission_groups (name, description, features)
VALUES (
'Content Manager',
'Can manage all content but not system settings',
ARRAY['events.create', 'events.edit', 'events.publish', 'speakers.manage', 'calendars.manage']
);
-- Registration Manager group
INSERT INTO admin_permission_groups (name, description, features)
VALUES (
'Registration Manager',
'Can manage registrations and send email',
ARRAY['registrations.manage', 'email.send']
);Assign a group to an admin:
INSERT INTO admin_permission_group_assignments (admin_id, group_id, assigned_by)
VALUES ('admin-uuid', 'group-uuid', 'super-admin-uuid');All permission changes are logged in the admin_permission_audit table:
CREATE TABLE public.admin_permission_audit (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid,
feature text,
action text CHECK (action IN ('grant', 'revoke')),
performed_by uuid,
performed_at timestamptz NOT NULL DEFAULT now(),
details jsonb
);This provides an immutable record of who granted or revoked which permissions and when.
When using an external OIDC provider instead of Supabase Auth, Row Level Security (RLS) policies need special handling since the user is not in Supabase's auth.users table.
- The API server authenticates the user via OIDC and obtains their identity.
- The API server looks up the user's
admin_profilesrecord by email. - For Supabase queries, the API server uses the service role key which bypasses RLS.
- Authorization checks are performed at the application level using the permissions system.
The SERVICE_ROLE_KEY bypasses all RLS policies. It must never be exposed to the client. Only server-side code (API server, worker, scheduler) should use it.
// Server-side only
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);The public portal uses the anon key with RLS policies that allow public read access to published events, active calendars, and related data. These policies work regardless of the auth provider because they do not depend on auth.uid().
Example RLS policy for public event access:
CREATE POLICY "Public can view published events"
ON public.events
FOR SELECT
TO anon
USING (status = 'published');