Skip to main content

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

StepDescription
1-3Frontend authenticates with your backend (via WorkOS, etc.)
4Backend mints a Supabase-compatible JWT signed with Legacy JWT Secret
5Frontend connects to Supabase Realtime using Anon Key + custom JWT
6-7Supabase verifies JWT and applies RLS policies
8Database changes are broadcast only to authorized users

2. API Keys Explained

Where to Find Keys

Supabase Dashboard → Settings → API

Keys You Need

KeyLocationUsed ByPurpose
Project URLSettings → APIFrontendhttps://<project-ref>.supabase.co
Anon KeySettings → API → Project API keysFrontendPublic key for Supabase client initialization
Legacy JWT SecretSettings → API → JWT SettingsBackendSecret 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 (anon role)
// 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

ClaimRequiredDescription
audYesMust be "authenticated" for RLS to work
expYesExpiration timestamp (Unix seconds)
iatYesIssued at timestamp (Unix seconds)
issRecommendedYour backend identifier
subRecommendedSubject (usually user ID)
roleYesMust be "authenticated" to use authenticated role policies
user_idCustomYour internal user ID (for RLS)
org_idCustomYour 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

  1. Go to Database → Publications
  2. Find supabase_realtime
  3. 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');
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

Settingold contains on UPDATEold contains on DELETE
DEFAULTPrimary key onlyPrimary key only
FULLAll columnsAll 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 in old, 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:

CauseSolution
RLS not enabledALTER TABLE ... ENABLE ROW LEVEL SECURITY;
No SELECT policyCreate a policy for authenticated role
Policy on wrong roleEnsure policy is TO authenticated, not TO public
JWT expiredGenerate a fresh token
Wrong JWT secretVerify you're using Legacy JWT Secret
Policy condition failsTest policy logic with permissive USING (true)

Issue: Events Not Received At All

Causes & Solutions:

CauseSolution
Table not in publicationALTER PUBLICATION supabase_realtime ADD TABLE ...;
Wrong channel nameDon't use 'realtime' as channel name
setAuth() not calledCall supabase.realtime.setAuth(token) before subscribing
WebSocket not connectedCheck 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 │
│ │
└─────────────────────────────────────────────────────────────────┘

Additional Resources