Supabase Realtime with Custom JWT Authentication¶
Complete Setup Guide¶
This guide covers how to set up Supabase Realtime with a custom authentication system (e.g., WorkOS, Auth0, or your own backend) instead of Supabase Auth.
Table of Contents¶
- Architecture Overview
- API Keys Explained
- Backend: Minting Custom JWTs
- Database: Enable Realtime Publication
- Database: Enable Row Level Security (RLS)
- Database: Create RLS Policies
- Database: Configure Replica Identity
- Database: Grant Permissions
- Frontend: Connect to Realtime
- Testing & Debugging
- Common Issues & Solutions
- Complete SQL Setup Script
1. Architecture Overview¶
sequenceDiagram
autonumber
participant FE as Frontend(Electron/Web)
participant BE as Your Backend(Go/Node)
participant SR as SupabaseRealtime
participant DB as PostgresDatabase
Note over FE,DB: Authentication Flow
FE->>BE: 1. Authenticate (via WorkOS, etc.)
BE->>BE: 2. Mint JWT with Legacy JWT Secret
BE-->>FE: 3. Return signed JWT
Note over FE,DB: Realtime Connection
FE->>SR: 4. Connect WebSocket + JWT
SR->>SR: 5. Verify JWT signature
SR-->>FE: 6. Connection established
Note over FE,DB: Realtime Events
DB->>SR: 7. Database change occurs
SR->>SR: 8. Check RLS policiesagainst JWT claims
SR-->>FE: 9. Broadcast event(if authorized)
flowchart TB
subgraph Frontend
A[Electron/React App]
end
subgraph Backend
B[Go Backend]
C[WorkOS Auth]
end
subgraph Supabase
D[Realtime Server]
E[(Postgres DB)]
end
A -->|1. Login| C
C -->|2. User verified| B
B -->|3. Mint JWTLegacy JWT Secret| B
B -->|4. Return JWT| A
A -->|5. WebSocket + JWTAnon Key| D
D -->|6. Verify & Apply RLS| E
E -->|7. Changes| D
D -->|8. Broadcast| A
style A fill:#61dafb
style B fill:#00add8
style C fill:#6363f1
style D fill:#3ecf8e
style E fill:#3ecf8e
Flow Summary¶
| Step | Description |
|---|---|
| 1-3 | Frontend authenticates with your backend (via WorkOS, etc.) |
| 4 | Backend mints a Supabase-compatible JWT signed with Legacy JWT Secret |
| 5 | Frontend connects to Supabase Realtime using Anon Key + custom JWT |
| 6-7 | Supabase verifies JWT and applies RLS policies |
| 8 | Database changes are broadcast only to authorized users |
2. API Keys Explained¶
Where to Find Keys¶
Supabase Dashboard → Settings → API
Keys You Need¶
| Key | Location | Used By | Purpose |
|---|---|---|---|
| Project URL | Settings → API | Frontend | https://<project-ref>.supabase.co |
| Anon Key | Settings → API → Project API keys | Frontend | Public key for Supabase client initialization |
| Legacy JWT Secret | Settings → API → JWT Settings | Backend | Secret to sign custom JWTs |
Key Details¶
Anon Key (Frontend)¶
- Starts with
eyJ...(it's a JWT itself) - Safe to expose in frontend code
- Used to initialize the Supabase client
- Provides base access level (
anonrole)
// Frontend usage
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
Legacy JWT Secret (Backend Only)¶
- Long base64 string (e.g.,
+Mp3vRMF...==) - NEVER expose in frontend
- Used to sign custom JWTs that Supabase will trust
- Store in environment variables
// Backend usage (Go)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.HS256, []byte(jwtSecret)))
3. Backend: Minting Custom JWTs¶
Required JWT Claims¶
{
"aud": "authenticated",
"exp": 1768664544,
"iat": 1768660944,
"iss": "your-backend-name",
"sub": "30",
"role": "authenticated",
// Custom claims for RLS policies
"user_id": "30",
"org_id": "45"
}
Claim Descriptions¶
| Claim | Required | Description |
|---|---|---|
aud |
Yes | Must be "authenticated" for RLS to work |
exp |
Yes | Expiration timestamp (Unix seconds) |
iat |
Yes | Issued at timestamp (Unix seconds) |
iss |
Recommended | Your backend identifier |
sub |
Recommended | Subject (usually user ID) |
role |
Yes | Must be "authenticated" to use authenticated role policies |
user_id |
Custom | Your internal user ID (for RLS) |
org_id |
Custom | Your internal organization ID (for RLS) |
Go Implementation¶
package service
import (
"time"
"strconv"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
)
type RealtimeService struct {
jwtSecret []byte // Legacy JWT Secret from Supabase
}
func NewRealtimeService(jwtSecret string) *RealtimeService {
return &RealtimeService{
jwtSecret: []byte(jwtSecret),
}
}
func (s *RealtimeService) GenerateToken(userID, orgID int64) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Issuer("your-backend-name").
Audience([]string{"authenticated"}).
Subject(strconv.FormatInt(userID, 10)).
Claim("role", "authenticated").
Claim("user_id", strconv.FormatInt(userID, 10)).
Claim("org_id", strconv.FormatInt(orgID, 10)).
JwtID(uuid.NewString()).
IssuedAt(now).
Expiration(now.Add(1 * time.Hour)). // 1 hour expiry
Build()
if err != nil {
return "", err
}
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.HS256, s.jwtSecret))
if err != nil {
return "", err
}
return string(signedToken), nil
}
¶
package service
import (
"time"
"strconv"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
)
type RealtimeService struct {
jwtSecret []byte // Legacy JWT Secret from Supabase
}
func NewRealtimeService(jwtSecret string) *RealtimeService {
return &RealtimeService{
jwtSecret: []byte(jwtSecret),
}
}
func (s *RealtimeService) GenerateToken(userID, orgID int64) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Issuer("your-backend-name").
Audience([]string{"authenticated"}).
Subject(strconv.FormatInt(userID, 10)).
Claim("role", "authenticated").
Claim("user_id", strconv.FormatInt(userID, 10)).
Claim("org_id", strconv.FormatInt(orgID, 10)).
JwtID(uuid.NewString()).
IssuedAt(now).
Expiration(now.Add(1 * time.Hour)). // 1 hour expiry
Build()
if err != nil {
return "", err
}
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.HS256, s.jwtSecret))
if err != nil {
return "", err
}
return string(signedToken), nil
}
4. Database: Enable Realtime Publication¶
Supabase uses PostgreSQL's logical replication to detect changes. Tables must be added to the supabase_realtime publication.
Via Dashboard¶
- Go to Database → Publications
- Find
supabase_realtime - Toggle on the tables you want
Via SQL¶
-- Add tables to realtime publication
ALTER PUBLICATION supabase_realtime ADD TABLE public.rooms;
ALTER PUBLICATION supabase_realtime ADD TABLE public.users;
ALTER PUBLICATION supabase_realtime ADD TABLE public.organizations;
-- Verify tables are in the publication
SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
Remove Tables from Publication¶
ALTER PUBLICATION supabase_realtime DROP TABLE public.some_table;
5. Database: Enable Row Level Security (RLS)¶
RLS must be enabled for Realtime to filter events based on user permissions.
Enable RLS¶
-- Enable RLS on tables
ALTER TABLE public.rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
-- Verify RLS is enabled
SELECT tablename, rowsecurity as rls_enabled
FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN ('rooms', 'users', 'organizations');
Disable RLS (Not Recommended for Production)¶
ALTER TABLE public.rooms DISABLE ROW LEVEL SECURITY;
6. Database: Create RLS Policies¶
RLS policies determine which rows each user can see. For Realtime, you need SELECT policies.
Policy Syntax¶
CREATE POLICY "policy_name" ON schema.table
FOR SELECT -- Operation: SELECT, INSERT, UPDATE, DELETE, ALL
TO role_name -- Role: authenticated, anon, or custom role
USING (condition); -- Row filter condition
Accessing JWT Claims in Policies¶
Use auth.jwt() function to access claims from your custom JWT:
-- Get a claim as text
auth.jwt() ->> 'org_id' -- Returns '45' (text)
-- Get a claim and cast to integer
(auth.jwt() ->> 'org_id')::int -- Returns 45 (integer)
-- Get nested claims
auth.jwt() -> 'app_metadata' ->> 'role'
Example Policies¶
Users Can See Rooms in Their Organization¶
CREATE POLICY "rooms_select_by_org" ON public.rooms
FOR SELECT TO authenticated
USING (org_id::text = (auth.jwt() ->> 'org_id'));
Users Can See Only Their Own Record¶
CREATE POLICY "users_select_own" ON public.users
FOR SELECT TO authenticated
USING (id::text = (auth.jwt() ->> 'user_id'));
Users Can See Their Organization¶
CREATE POLICY "organizations_select_own" ON public.organizations
FOR SELECT TO authenticated
USING (id::text = (auth.jwt() ->> 'org_id'));
Allow All Authenticated Users (Permissive)¶
CREATE POLICY "allow_all_authenticated" ON public.some_table
FOR SELECT TO authenticated
USING (true);
View Existing Policies¶
SELECT tablename, policyname, roles, cmd, qual as using_expression
FROM pg_policies
WHERE schemaname = 'public';
Drop a Policy¶
DROP POLICY IF EXISTS "policy_name" ON public.table_name;
7. Database: Configure Replica Identity¶
Replica identity determines what data is included in the old field for UPDATE and DELETE events.
Options¶
| Setting | old contains on UPDATE |
old contains on DELETE |
|---|---|---|
DEFAULT |
Primary key only | Primary key only |
FULL |
All columns | All columns (but only PK if RLS enabled) |
Enable Full Replica Identity¶
-- Get full row data in 'old' field for UPDATE events
ALTER TABLE public.rooms REPLICA IDENTITY FULL;
ALTER TABLE public.users REPLICA IDENTITY FULL;
ALTER TABLE public.organizations REPLICA IDENTITY FULL;
Check Current Setting¶
SELECT relname, relreplident
FROM pg_class
WHERE relname IN ('rooms', 'users', 'organizations');
-- relreplident: 'd' = default, 'f' = full, 'n' = nothing, 'i' = index
Important Note¶
When RLS is enabled and replica identity is set to
full, DELETE events will only include the primary key inold, not the full row. This is because Postgres cannot verify RLS permissions on deleted rows.
8. Database: Grant Permissions¶
If you're using an ORM like GORM that runs migrations, it may reset grants. Ensure proper permissions exist:
-- Grant schema usage
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT USAGE ON SCHEMA public TO anon;
-- Grant SELECT on tables
GRANT SELECT ON public.rooms TO authenticated;
GRANT SELECT ON public.users TO authenticated;
GRANT SELECT ON public.organizations TO authenticated;
-- If you need INSERT/UPDATE/DELETE via API too
GRANT INSERT, UPDATE, DELETE ON public.rooms TO authenticated;
-- Grant on all current and future tables (optional)
GRANT SELECT ON ALL TABLES IN SCHEMA public TO authenticated;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO authenticated;
9. Frontend: Connect to Realtime¶
Install Supabase Client¶
npm install @supabase/supabase-js
Basic Setup¶
// src/lib/supabase.ts
import { createClient, RealtimeChannel } from '@supabase/supabase-js';
const SUPABASE_URL = 'https://your-project.supabase.co';
const SUPABASE_ANON_KEY = 'eyJ...'; // Your anon key
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
Setting Custom JWT for Realtime¶
// IMPORTANT: Set auth BEFORE creating channels
export function setRealtimeAuth(token: string) {
supabase.realtime.setAuth(token);
}
Subscribing to Changes¶
// src/lib/realtime.ts
import { supabase, setRealtimeAuth } from './supabase';
export function subscribeToRooms(
token: string,
onInsert: (room: Room) => void,
onUpdate: (newRoom: Room, oldRoom: Room) => void,
onDelete: (oldRoom: Room) => void
): RealtimeChannel {
// Set the custom JWT
setRealtimeAuth(token);
const channel = supabase
.channel('rooms-changes')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'rooms',
},
(payload) => {
onInsert(payload.new as Room);
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'rooms',
},
(payload) => {
onUpdate(payload.new as Room, payload.old as Room);
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'rooms',
},
(payload) => {
onDelete(payload.old as Room);
}
)
.subscribe((status, err) => {
if (status === 'SUBSCRIBED') {
console.log('✅ Subscribed to rooms changes');
}
if (err) {
console.error('Subscription error:', err);
}
});
return channel;
}
// Cleanup function
export async function unsubscribe(channel: RealtimeChannel) {
await supabase.removeChannel(channel);
}
React Hook Example¶
// src/hooks/useRealtimeRooms.ts
import { useEffect, useRef, useState } from 'react';
import { RealtimeChannel } from '@supabase/supabase-js';
import { subscribeToRooms, unsubscribe } from '../lib/realtime';
export function useRealtimeRooms(token: string | null) {
const [rooms, setRooms] = useState<Room[]>([]);
const channelRef = useRef<RealtimeChannel | null>(null);
useEffect(() => {
if (!token) return;
const channel = subscribeToRooms(
token,
// On INSERT
(newRoom) => {
setRooms(prev => [...prev, newRoom]);
},
// On UPDATE
(newRoom, oldRoom) => {
setRooms(prev =>
prev.map(room =>
room.id === newRoom.id ? newRoom : room
)
);
},
// On DELETE
(oldRoom) => {
setRooms(prev =>
prev.filter(room => room.id !== oldRoom.id)
);
}
);
channelRef.current = channel;
return () => {
if (channelRef.current) {
unsubscribe(channelRef.current);
}
};
}, [token]);
return rooms;
}
Filtering Changes¶
// Only listen to rooms in a specific organization
const channel = supabase
.channel('org-rooms')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'rooms',
filter: 'org_id=eq.45', // Filter at subscription level
},
(payload) => console.log(payload)
)
.subscribe();
Token Refresh¶
// When token is refreshed, update Realtime auth
function onTokenRefresh(newToken: string) {
supabase.realtime.setAuth(newToken);
}
10. Testing & Debugging¶
Test Script¶
// test-realtime.js
const { createClient } = require('@supabase/supabase-js');
const SUPABASE_URL = 'https://your-project.supabase.co';
const SUPABASE_ANON_KEY = 'eyJ...';
const TOKEN = process.argv[2];
if (!TOKEN) {
console.log('Usage: node test-realtime.js <JWT_TOKEN>');
process.exit(1);
}
// Decode and validate token
const payload = JSON.parse(Buffer.from(TOKEN.split('.')[1], 'base64').toString());
console.log('Token payload:', payload);
console.log('Expires:', new Date(payload.exp * 1000).toISOString());
console.log('Valid:', payload.exp * 1000 > Date.now() ? '✅ YES' : '❌ EXPIRED');
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
supabase.realtime.setAuth(TOKEN);
const channel = supabase
.channel('test')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'rooms' },
(payload) => {
console.log('Event:', payload.eventType);
console.log('New:', payload.new);
console.log('Old:', payload.old);
console.log('Errors:', payload.errors);
}
)
.subscribe((status, err) => {
console.log('Status:', status);
if (err) console.error('Error:', err);
});
Trigger Test Events¶
-- INSERT
INSERT INTO rooms (name, org_id, room_id)
VALUES ('Test Room', 45, gen_random_uuid());
-- UPDATE
UPDATE rooms SET updated_at = NOW() WHERE org_id = 45;
-- DELETE
DELETE FROM rooms WHERE name = 'Test Room';
Debug Queries¶
-- Check RLS is enabled
SELECT tablename, rowsecurity FROM pg_tables
WHERE schemaname = 'public';
-- Check policies
SELECT tablename, policyname, roles, cmd
FROM pg_policies WHERE schemaname = 'public';
-- Check publication
SELECT * FROM pg_publication_tables
WHERE pubname = 'supabase_realtime';
-- Check grants
SELECT grantee, table_name, privilege_type
FROM information_schema.table_privileges
WHERE table_schema = 'public';
11. Common Issues & Solutions¶
Issue: 401 Unauthorized with Empty Payload¶
{
"new": {},
"old": {},
"errors": ["Error 401: Unauthorized"]
}
Causes & Solutions:
| Cause | Solution |
|---|---|
| RLS not enabled | ALTER TABLE ... ENABLE ROW LEVEL SECURITY; |
| No SELECT policy | Create a policy for authenticated role |
| Policy on wrong role | Ensure policy is TO authenticated, not TO public |
| JWT expired | Generate a fresh token |
| Wrong JWT secret | Verify you're using Legacy JWT Secret |
| Policy condition fails | Test policy logic with permissive USING (true) |
Issue: Events Not Received At All¶
Causes & Solutions:
| Cause | Solution |
|---|---|
| Table not in publication | ALTER PUBLICATION supabase_realtime ADD TABLE ...; |
| Wrong channel name | Don't use 'realtime' as channel name |
setAuth() not called |
Call supabase.realtime.setAuth(token) before subscribing |
| WebSocket not connected | Check subscribe() status callback |
Issue: Missing Grants (Common with ORMs)¶
-- Fix grants after ORM migrations
GRANT USAGE ON SCHEMA public TO postgres, anon, authenticated, service_role;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO postgres, anon, authenticated, service_role;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO postgres, anon, authenticated, service_role;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
Issue: old Field is Empty on UPDATE¶
-- Enable full replica identity
ALTER TABLE public.rooms REPLICA IDENTITY FULL;
Issue: Migration Version Mismatch¶
-- Check migration state
SELECT * FROM schema_migrations;
-- Reset to specific version
DELETE FROM schema_migrations WHERE version > 3;
UPDATE schema_migrations SET dirty = false WHERE version = 3;
12. Complete SQL Setup Script¶
Run this script to set up everything at once:
-- =====================================================
-- SUPABASE REALTIME SETUP SCRIPT
-- =====================================================
-- 1. ENABLE RLS
-- =====================================================
ALTER TABLE public.rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
-- 2. GRANT PERMISSIONS
-- =====================================================
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT USAGE ON SCHEMA public TO anon;
GRANT SELECT ON public.rooms TO authenticated;
GRANT SELECT ON public.users TO authenticated;
GRANT SELECT ON public.organizations TO authenticated;
-- 3. CREATE RLS POLICIES
-- =====================================================
-- Rooms: Users see rooms in their organization
DROP POLICY IF EXISTS "rooms_select_by_org" ON public.rooms;
CREATE POLICY "rooms_select_by_org" ON public.rooms
FOR SELECT TO authenticated
USING (org_id::text = (auth.jwt() ->> 'org_id'));
-- Users: Users see only their own record
DROP POLICY IF EXISTS "users_select_own" ON public.users;
CREATE POLICY "users_select_own" ON public.users
FOR SELECT TO authenticated
USING (id::text = (auth.jwt() ->> 'user_id'));
-- Organizations: Users see their organization
DROP POLICY IF EXISTS "organizations_select_own" ON public.organizations;
CREATE POLICY "organizations_select_own" ON public.organizations
FOR SELECT TO authenticated
USING (id::text = (auth.jwt() ->> 'org_id'));
-- 4. ADD TABLES TO REALTIME PUBLICATION
-- =====================================================
ALTER PUBLICATION supabase_realtime ADD TABLE public.rooms;
ALTER PUBLICATION supabase_realtime ADD TABLE public.users;
ALTER PUBLICATION supabase_realtime ADD TABLE public.organizations;
-- 5. ENABLE FULL REPLICA IDENTITY (for old values in UPDATE)
-- =====================================================
ALTER TABLE public.rooms REPLICA IDENTITY FULL;
ALTER TABLE public.users REPLICA IDENTITY FULL;
ALTER TABLE public.organizations REPLICA IDENTITY FULL;
-- 6. VERIFY SETUP
-- =====================================================
SELECT 'RLS Status' as check_type, tablename, rowsecurity as enabled
FROM pg_tables
WHERE schemaname = 'public' AND tablename IN ('rooms', 'users', 'organizations')
UNION ALL
SELECT 'Policy', tablename || ': ' || policyname, true
FROM pg_policies
WHERE schemaname = 'public' AND tablename IN ('rooms', 'users', 'organizations')
UNION ALL
SELECT 'Publication', tablename, true
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime' AND tablename IN ('rooms', 'users', 'organizations');
Quick Reference Card¶
┌─────────────────────────────────────────────────────────────────┐
│ SUPABASE REALTIME CHECKLIST │
├─────────────────────────────────────────────────────────────────┤
│ │
│ KEYS │
│ ├─ Frontend: Anon Key (eyJ...) │
│ └─ Backend: Legacy JWT Secret (for signing) │
│ │
│ JWT CLAIMS (Required) │
│ ├─ aud: "authenticated" │
│ ├─ role: "authenticated" │
│ ├─ exp: Unix timestamp │
│ └─ Custom: user_id, org_id (for RLS) │
│ │
│ DATABASE SETUP │
│ ├─ ALTER TABLE ... ENABLE ROW LEVEL SECURITY; │
│ ├─ CREATE POLICY ... TO authenticated USING (...); │
│ ├─ ALTER PUBLICATION supabase_realtime ADD TABLE ...; │
│ └─ GRANT SELECT ON ... TO authenticated; │
│ │
│ FRONTEND │
│ ├─ createClient(URL, ANON_KEY) │
│ ├─ supabase.realtime.setAuth(CUSTOM_JWT) // BEFORE subscribe │
│ └─ .channel('name').on('postgres_changes', ...).subscribe() │
│ │
│ DEBUG │
│ ├─ 401 Error → Check RLS policies & JWT claims │
│ ├─ No events → Check publication & channel setup │
│ └─ Empty old → Enable REPLICA IDENTITY FULL │
│ │
└─────────────────────────────────────────────────────────────────┘