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.
1. Architecture Overview
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
}
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 │
│ │
└─────────────────────────────────────────────────────────────────┘