Build a secure realtime chat application with RDS Supabase Realtime

Updated at:

RDS Supabase Realtime combines PostgreSQL Write-Ahead Log (WAL) change data capture (CDC), private channels, and Row Level Security (RLS) to push database changes to authorized subscribers in real time. This topic shows how to build a private chat application—one-to-one and group chats—with end-to-end access control enforced at the database layer.

Overview

RDS Supabase Realtime moves both the delivery and the authorization into PostgreSQL, so that the same RLS policies that protect table reads also protect the WebSocket channel.

Core capabilities

  • Channel-level authorization. The realtime.messages RLS policy decides who can subscribe to a given topic. Non-members cannot even join the WebSocket channel, cutting off data exposure at the source.

  • Row-level filtering. Business-table RLS policies decide which rows each user sees. Even if channel authorization is bypassed, SELECT queries only return rows the user is authorized to read.

  • Realtime push. Once a message is written to PostgreSQL, the WAL captures the change and Realtime pushes it to authorized subscribers, typically within milliseconds.

  • Message persistence. Messages live in PostgreSQL, so offline users can replay history without a separate sync mechanism.

Private channels compared with public channels

Dimension

Public channel

Private channel

Subscription control

Anyone can subscribe

Only users who pass RLS

Data delivery

All subscribers receive all changes

Delivery filtered per-user by RLS

Typical use cases

Announcements, public dashboards

Direct messages, multi-tenant apps, regulated data

Required configuration

Publication

Publication + realtime.messages RLS + private: true

Runtime overhead

Low

Slightly higher (RLS evaluated per delivery)

Prerequisites

Before you begin, make sure you have:

  • An ApsaraDB RDS for PostgreSQL instance with Supabase enabled, and the Supabase project's URL and anonymous key. For details, see Use the RDS Supabase SDK.

  • Node.js 18 or later and npm installed on your local machine (required for running the sample application).

  • The client IP address of your development machine added to the Supabase whitelist.

Architecture and security model

The chat application enforces two independent layers of RLS. Each layer is sufficient on its own to prevent unauthorized access; together they make accidental data exposure practically impossible.

Two layers of Row Level Security

  • Layer 1 — Channel authorization. Enforced by the realtime.messages RLS policy. Non-members cannot join the WebSocket channel at all.

  • Layer 2 — Data filtering. Enforced by the business-table (dm_messages) RLS policy. Even if layer 1 is bypassed, SELECT queries return only the caller's own messages.

How channel authorization works

When a client sends a phx_join request over WebSocket, the Realtime service executes a SELECT against realtime.messages inside a transaction that is immediately rolled back. The query is only there to evaluate RLS. If the policy rejects it, the server returns You do not have permissions to read from this Channel topic and the subscription fails.

Data delivery uses a different code path: business-table changes are captured from the WAL, evaluated against the table's RLS policy per subscriber, and pushed only to users who pass. Because the two layers are enforced separately, a mistake in one policy does not create a hole in the other.

Usage notes and best practices

Read this section before you write any SQL or client code. The rules below are prescriptive: skipping them is the most common cause of silent failures reported in production chat systems.

Database configuration

  • Set REPLICA IDENTITY FULL on every message table. Without this, WAL change events only carry primary-key columns and clients receive incomplete record payloads.

  • Add the table to the supabase_realtime publication. Tables that are not part of this publication are invisible to Realtime and no events are pushed.

  • Expect realtime.messages to always be empty. Realtime only uses this table to evaluate authorization and rolls back the transaction; no rows are persisted.

Topic naming

  • Keep topics unique per conversation. Use a predictable format such as dm:{conversation_id} for one-to-one chats and group:{conversation_id} for group chats.

  • Predictable topics are safe with RLS. Any client can attempt to subscribe using a guessed topic; the RLS policy rejects requests from non-members before the channel is joined.

JWT and connection management

  • Heartbeats keep connections alive. The Supabase JavaScript SDK sends a heartbeat every 30 seconds. Native WebSocket clients must send heartbeats manually or the server disconnects them.

  • Refresh JWTs before they expire. The SDK auto-refreshes via access_token events. Native clients must implement token refresh themselves.

  • Watch PostgreSQL connection quotas. The Realtime module holds PostgreSQL connections up to the role's rolconnlimit. Size the quota for peak concurrent subscribers, not average traffic.

Performance

  • Store conversation_topic redundantly on the members table. This lets the channel-authorization RLS use a single-table lookup instead of a JOIN, which is the largest lever on subscription latency at scale.

  • Push filters to the server. Set the filter parameter in postgres_changes so Realtime only sends events for the current conversation. Client-side filtering wastes bandwidth and CPU.

  • Paginate history loads. Fetch the most recent N messages when a chat opens and lazy-load older pages on scroll.

Security

  • Never disable either RLS layer. Channel authorization prevents connection; row-level filtering protects the data. Removing one is not a small optimization—it is the entire safety net.

  • Use SECURITY DEFINER only when the function does its own permission check. These functions bypass RLS, so validate auth.uid() and other inputs inside the function body.

  • Always validate sender_id = auth.uid() in the INSERT policy. This prevents authenticated users from writing messages as if they were someone else.

Get the sample code

Download the sample code archive and extract it to a local directory. The database preparation and application run in later sections operate on files from the extracted project.

  1. Download the archive from the sample code download page, for example supabase-realtime-chat.zip.

  2. Extract the archive into your working directory, then change into the extracted project root:

    unzip supabase-realtime-chat.zip
    cd supabase-realtime-chat
Note

The sample code download URL will be published together with the source code. Contact your Alibaba Cloud RDS support representative if you need early access.

Project structure

The extracted project has the following structure. Later steps reference sql/init.sql and .env.example:

src/
├── App.tsx                          # Root component (routing + AuthProvider)
├── App.css                          # Global styles
├── main.tsx                         # Entry point
├── lib/
│   ├── supabase.ts                  # Supabase client
│   └── auth.tsx                     # Auth context
├── components/
│   ├── Auth.tsx                     # Sign in / sign up form
│   ├── ChatApp.tsx                  # Main layout (conversation list + chat window)
│   ├── ConversationList.tsx         # Conversation list sidebar
│   ├── ChatWindow.tsx               # Chat window (messages + input)
│   └── CreateConversationModal.tsx  # New conversation modal
└── types/
    └── database.ts                  # TypeScript type definitions
sql/
└── init.sql                         # Database initialization script
.env.example                         # Environment variables template

Prepare the database

The database preparation consists of five parts: create the business tables, enable RLS and grant privileges, define the RLS policies, configure Realtime, and create the helper functions. Open the sql/init.sql script from the sample repository in the Supabase SQL Editor and run it once to complete all preparation in a single pass. The subsections that follow show the corresponding SQL snippets for reference — read them alongside the source, or run individual blocks when you need to.

Create the business tables

The application uses four tables. Run the following statements in the Supabase SQL Editor:

-- User profiles (populated automatically by an auth trigger)
CREATE TABLE public.profiles (
    id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
    email text,
    created_at timestamptz DEFAULT now()
);

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
    INSERT INTO public.profiles (id, email) VALUES (NEW.id, NEW.email);
    RETURN NEW;
END;
$$;

CREATE TRIGGER on_auth_user_created
    AFTER INSERT ON auth.users
    FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

-- Conversations (one row per DM or group)
CREATE TABLE public.conversations (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    topic text NOT NULL UNIQUE,          -- Realtime channel topic
    name text,                           -- Group name (null for DMs)
    is_group boolean DEFAULT false,      -- true = group, false = 1:1 DM
    created_at timestamptz DEFAULT now()
);

-- Membership (with redundant conversation_topic for fast RLS lookups)
CREATE TABLE public.conversation_members (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    conversation_id bigint REFERENCES public.conversations(id) ON DELETE CASCADE,
    user_id uuid NOT NULL,
    conversation_topic text NOT NULL,
    joined_at timestamptz DEFAULT now(),
    UNIQUE(conversation_id, user_id)
);

CREATE INDEX idx_members_user_topic ON public.conversation_members(user_id, conversation_topic);
CREATE INDEX idx_members_conversation ON public.conversation_members(conversation_id);

-- Messages
CREATE TABLE public.dm_messages (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    conversation_id bigint REFERENCES public.conversations(id) ON DELETE CASCADE,
    sender_id uuid NOT NULL,
    content text NOT NULL,
    created_at timestamptz DEFAULT now()
);

CREATE INDEX idx_messages_conversation ON public.dm_messages(conversation_id, created_at DESC);
Note

Supabase does not expose auth.users through the REST API. The profiles table plus trigger pattern is the standard workaround for user discovery.

Enable RLS and grant privileges

RLS must be enabled on every business table; policies are inert otherwise. Then grant the minimum required privileges to the authenticated role.

ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dm_messages ENABLE ROW LEVEL SECURITY;

-- Profiles policies: readable by all authenticated users; users can only insert their own row.
CREATE POLICY profiles_select ON public.profiles
FOR SELECT TO authenticated
USING (true);

CREATE POLICY profiles_insert ON public.profiles
FOR INSERT TO authenticated
WITH CHECK (id = auth.uid());

-- Grants
GRANT SELECT ON public.profiles TO authenticated;
GRANT SELECT ON public.conversations TO authenticated;
GRANT SELECT ON public.conversation_members TO authenticated;
GRANT SELECT, INSERT ON public.dm_messages TO authenticated;

Define the RLS policies

Three sets of policies work together to enforce the double-layer security model.

Channel authorization — the first layer, evaluated when a client joins a WebSocket topic.

CREATE POLICY dm_channel_subscribe
ON realtime.messages
FOR SELECT TO authenticated
USING (
    EXISTS (
        SELECT 1 FROM public.conversation_members m
        WHERE m.user_id = auth.uid()
          AND m.conversation_topic = realtime.topic()
    )
);

realtime.topic() returns the topic name of the current subscription; auth.uid() returns the caller's UUID. The policy allows the subscription only when the caller is a member of the conversation that owns the topic.

Message read/write — the second layer, evaluated on every query and insert.

-- Members can read messages from their conversations.
CREATE POLICY member_select ON public.dm_messages
FOR SELECT TO authenticated
USING (
    EXISTS (
        SELECT 1 FROM public.conversation_members m
        WHERE m.conversation_id = dm_messages.conversation_id
          AND m.user_id = auth.uid()
    )
);

-- Members can send messages only as themselves.
CREATE POLICY member_insert ON public.dm_messages
FOR INSERT TO authenticated
WITH CHECK (
    sender_id = auth.uid()
    AND EXISTS (
        SELECT 1 FROM public.conversation_members m
        WHERE m.conversation_id = dm_messages.conversation_id
          AND m.user_id = auth.uid()
    )
);

The INSERT policy checks two conditions: the sender_id must match the caller (preventing impersonation), and the caller must be a member of the target conversation.

Conversation and membership visibility — required so that the client can list conversations and members.

-- Authenticated users can read membership rows (for member lists in the UI).
CREATE POLICY members_auth_select ON public.conversation_members
FOR SELECT TO authenticated
USING (true);

-- Users can only see conversations they are a member of.
CREATE POLICY conv_member_select ON public.conversations
FOR SELECT TO authenticated
USING (
    EXISTS (
        SELECT 1 FROM public.conversation_members m
        WHERE m.conversation_id = conversations.id
          AND m.user_id = auth.uid()
    )
);

Configure Realtime

Two settings are required for the message table to be visible to Realtime:

-- 1. Emit complete row data in WAL events.
ALTER TABLE public.dm_messages REPLICA IDENTITY FULL;

-- 2. Add the message table to the Realtime publication.
ALTER PUBLICATION supabase_realtime ADD TABLE public.dm_messages;
Important

Without REPLICA IDENTITY FULL, WAL change events contain only primary-key columns and the record payload delivered to clients is missing content, sender_id, and other business columns.

Create the helper functions

These helper functions bundle multi-table writes into a single RPC call and are safe to call from the client. Both are SECURITY DEFINER, so validate inputs inside the function body.

Idempotent one-to-one conversation. Returns the existing conversation if one already exists between the two users; otherwise creates a new one.

CREATE OR REPLACE FUNCTION public.create_dm_conversation(other_user_id uuid)
RETURNS bigint LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
    conv_id bigint;
    conv_topic text;
    current_uid uuid := auth.uid();
BEGIN
    IF current_uid = other_user_id THEN
        RAISE EXCEPTION 'Cannot create DM with yourself';
    END IF;

    -- Return the existing DM if one already exists.
    SELECT c.id INTO conv_id
    FROM public.conversations c
    JOIN public.conversation_members m1 ON m1.conversation_id = c.id AND m1.user_id = current_uid
    JOIN public.conversation_members m2 ON m2.conversation_id = c.id AND m2.user_id = other_user_id
    WHERE c.is_group = false
    LIMIT 1;

    IF conv_id IS NOT NULL THEN RETURN conv_id; END IF;

    -- Insert with a placeholder topic, then rewrite once the id is known.
    INSERT INTO public.conversations (topic, is_group) VALUES ('temp', false) RETURNING id INTO conv_id;
    conv_topic := 'dm:' || conv_id;
    UPDATE public.conversations SET topic = conv_topic WHERE id = conv_id;

    INSERT INTO public.conversation_members (conversation_id, user_id, conversation_topic)
    VALUES (conv_id, current_uid, conv_topic), (conv_id, other_user_id, conv_topic);

    RETURN conv_id;
END;
$$;
Note

Do not use currval() to get the sequence value inside a SECURITY DEFINER function. The sequence's currval may not be set in the current session. Insert a placeholder topic first, then update it once RETURNING id gives you the real id.

Group conversation. The same RLS policies work for groups; you only need to insert more member rows.

CREATE OR REPLACE FUNCTION public.create_group_conversation(group_name text, member_ids uuid[])
RETURNS bigint LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
    conv_id bigint;
    conv_topic text;
    current_uid uuid := auth.uid();
    uid uuid;
BEGIN
    INSERT INTO public.conversations (topic, name, is_group)
    VALUES ('group:placeholder', group_name, true) RETURNING id INTO conv_id;
    conv_topic := 'group:' || conv_id;
    UPDATE public.conversations SET topic = conv_topic WHERE id = conv_id;

    INSERT INTO public.conversation_members (conversation_id, user_id, conversation_topic)
    VALUES (conv_id, current_uid, conv_topic);

    FOREACH uid IN ARRAY member_ids LOOP
        IF uid != current_uid THEN
            INSERT INTO public.conversation_members (conversation_id, user_id, conversation_topic)
            VALUES (conv_id, uid, conv_topic)
            ON CONFLICT (conversation_id, user_id) DO NOTHING;
        END IF;
    END LOOP;

    RETURN conv_id;
END;
$$;

-- Grant execute privileges to the authenticated role.
GRANT EXECUTE ON FUNCTION public.create_dm_conversation(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.create_group_conversation(text, uuid[]) TO authenticated;

Run the sample application

Once the database is prepared, complete these three steps to start the frontend locally:

  1. Configure environment variables. Copy the example file and fill in your Supabase project URL and anonymous key.

    cp .env.example .env

    Edit .env:

    VITE_SUPABASE_URL=https://your-project.supabase.example.com
    VITE_SUPABASE_ANON_KEY=your-anon-key-here
  2. Install dependencies and start the dev server.

    npm install
    npm run dev

    Open http://localhost:5173 in your browser.

  3. Verify end-to-end delivery.

    1. Register two accounts. Use two browser windows (an incognito window works well) so each account has its own session.

    2. In the first account, click the + button to create a conversation.

    3. On the Direct message tab, select the second account.

    4. Send a message from either side and confirm it appears in real time on the other side without a page refresh.

    5. Refresh the browser; the conversation history reloads from PostgreSQL.

    If messages do not arrive, the most likely causes are missing REPLICA IDENTITY FULL, the message table not being in the Realtime publication, or a client that does not pass private: true. See Usage notes and best practices.

Client integration in depth

This section explains what the sample application does under the hood and how to adapt the pattern to your own frontend.

Subscribe to a private channel

Set private: true in the channel configuration. The SDK then attaches the caller's JWT to the subscription and Realtime evaluates the realtime.messages RLS policy before joining the topic.

const channel = supabase.channel(conversation.topic, {
  config: {
    private: true,
    postgres_changes: [
      {
        event: 'INSERT',
        schema: 'public',
        table: 'dm_messages',
        filter: `conversation_id=eq.${conversationId}`,
      },
    ],
  },
})

channel.on(
  'postgres_changes',
  { event: 'INSERT', schema: 'public', table: 'dm_messages' },
  (payload) => {
    console.log('New message:', payload.new)
  }
)

channel.subscribe((status) => {
  // status: 'SUBSCRIBED' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED'
  if (status === 'SUBSCRIBED') {
    console.log('Subscribed')
  }
})

Key parameters

  • private: true: enables RLS evaluation on realtime.messages. Omitting this flag skips the check entirely.

  • filter: server-side filter so Realtime only pushes rows for the current conversation, reducing bandwidth and client work.

  • The SDK handles the 30-second heartbeat and JWT refresh automatically.

Send a message

Insert into the message table over REST. RLS validates the sender identity and membership; the WAL captures the change and Realtime pushes it to all authorized subscribers—including the sender, so you do not need to append the new message locally.

const { error } = await supabase.from('dm_messages').insert({
  conversation_id: conversationId,
  sender_id: user.id,
  content: 'Hello!',
})

Load message history

A standard SELECT returns only the caller's own conversations because RLS filters the result set.

const { data: messages } = await supabase
  .from('dm_messages')
  .select('*')
  .eq('conversation_id', conversationId)
  .order('created_at', { ascending: true })
  .limit(100)

Clean up the subscription

Always remove the channel when the component unmounts. Leaked channels tie up server resources and leave the client sending heartbeats for a connection nobody reads.

return () => {
  supabase.removeChannel(channel)
}

End-to-end message flow

Following a message from the client back to the client makes the double-layer security visible:

User A sends a message
  |
  v
INSERT INTO dm_messages  (RLS: member + sender_id matches auth.uid())
  |
  v
PostgreSQL WAL captures the change
  |
  v
Realtime CDC evaluates RLS per subscriber
  |
  +--> User B is a member  --> push
  +--> User A is the sender --> push (sender receives own message)
  +--> User C is not a member --> no push

When a non-member tries to subscribe, the subscribe callback receives status CHANNEL_ERROR with the message You do not have permissions to read from this Channel topic: dm:xxx.

Advanced capabilities

Realtime supports two additional primitives that complement the persistent message flow. Both share the same channel object, so authentication and connection reuse them without extra setup.

Broadcast — transient signals

Broadcast is best for signals that do not need to persist, such as typing indicators or read receipts. Because Broadcast bypasses PostgreSQL, its latency is lower than a full CDC round-trip.

// Send a typing signal
channel.send({
  type: 'broadcast',
  event: 'typing',
  payload: { user_id: user.id, is_typing: true },
})

// Receive
channel.on('broadcast', { event: 'typing' }, (payload) => {
  console.log(`${payload.user_id} is typing...`)
})

Presence — online state

Presence tracks the set of users connected to a channel, so the UI can show who is currently online in a conversation.

// Announce yourself
channel.track({ user_id: user.id, online_at: new Date().toISOString() })

// React to state changes
channel.on('presence', { event: 'sync' }, () => {
  const state = channel.presenceState()
  console.log('Online:', Object.keys(state))
})

Read receipts

Store a per-user cursor in a small table and combine it with Broadcast to push read-state updates without polling.

CREATE TABLE public.read_receipts (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    conversation_id bigint REFERENCES conversations(id),
    user_id uuid NOT NULL,
    last_read_message_id bigint REFERENCES dm_messages(id),
    read_at timestamptz DEFAULT now(),
    UNIQUE(conversation_id, user_id)
);

Update the receipt row when the user scrolls past a message, and send a Broadcast event so other clients can update their unread badges without waiting for a WAL round-trip.