Skip to 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.


Table of Contents

  1. Architecture Overview
  2. API Keys Explained
  3. Backend: Minting Custom JWTs
  4. Database: Enable Realtime Publication
  5. Database: Enable Row Level Security (RLS)
  6. Database: Create RLS Policies
  7. Database: Configure Replica Identity
  8. Database: Grant Permissions
  9. Frontend: Connect to Realtime
  10. Testing & Debugging
  11. Common Issues & Solutions
  12. 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 (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

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

  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

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 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:

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                   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Additional Resources