Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Track Auth Events in Supabase

Track auth events in Supabase by creating a database trigger on auth.users that logs signups and profile changes to a custom audit table, and by listening to onAuthStateChange on the client for real-time event tracking. The trigger fires on every INSERT and UPDATE to auth.users, capturing the event type, user ID, and timestamp. The client-side listener captures SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, and PASSWORD_RECOVERY events.

What you'll learn

  • How to create a database trigger on auth.users to log signup and login events
  • How to build an audit_log table for persistent event tracking
  • How to use onAuthStateChange to track client-side auth events
  • How to query and analyze auth event history
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate9 min read15-20 minSupabase (all plans), @supabase/supabase-js v2+March 2026RapidDev Engineering Team
TL;DR

Track auth events in Supabase by creating a database trigger on auth.users that logs signups and profile changes to a custom audit table, and by listening to onAuthStateChange on the client for real-time event tracking. The trigger fires on every INSERT and UPDATE to auth.users, capturing the event type, user ID, and timestamp. The client-side listener captures SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, and PASSWORD_RECOVERY events.

Tracking Authentication Events with Database Triggers and Client-Side Listeners

Supabase provides two complementary approaches to auth event tracking. Server-side database triggers on auth.users capture every signup and user update at the database level, regardless of which client initiated the action. Client-side onAuthStateChange listeners capture login, logout, token refresh, and password recovery events in real time. This tutorial shows you how to combine both methods for complete auth event visibility, using a custom audit_log table to store the history.

Prerequisites

  • A Supabase project with authentication enabled
  • Access to the SQL Editor in the Supabase Dashboard
  • @supabase/supabase-js v2 installed in your frontend project
  • Basic understanding of PostgreSQL triggers and functions

Step-by-step guide

1

Create the audit_log table for storing auth events

Create a table that stores every auth event with a timestamp, user ID, event type, and metadata. Use a jsonb column for metadata so you can store flexible data like IP addresses, user agents, or provider information. Enable RLS and restrict access so only the service role can insert events (via the trigger function) and individual users can only read their own events.

typescript
1create table public.audit_log (
2 id bigint generated always as identity primary key,
3 user_id uuid not null,
4 event_type text not null,
5 metadata jsonb default '{}',
6 created_at timestamptz default now()
7);
8
9alter table public.audit_log enable row level security;
10
11-- Users can read their own audit events
12create policy "Users read own audit logs"
13on public.audit_log for select to authenticated
14using ((select auth.uid()) = user_id);
15
16-- Create an index for fast lookups by user and time
17create index idx_audit_log_user_id on public.audit_log (user_id, created_at desc);

Expected result: The audit_log table is created with RLS enabled. Users can only read their own audit events, and no one can insert directly via the API.

2

Create a trigger function to log auth.users changes

Write a PL/pgSQL function that fires on INSERT and UPDATE to the auth.users table. The function checks whether it is a new user (INSERT / signup) or an existing user update (UPDATE) and logs the appropriate event type. Use security definer so the function can write to the audit_log table regardless of RLS, and set search_path to empty string for security. The function captures the user's email, provider, and last sign-in time as metadata.

typescript
1create or replace function public.log_auth_event()
2returns trigger
3language plpgsql
4security definer set search_path = ''
5as $$
6declare
7 event_name text;
8 event_meta jsonb;
9begin
10 if TG_OP = 'INSERT' then
11 event_name := 'USER_SIGNUP';
12 event_meta := jsonb_build_object(
13 'email', new.email,
14 'provider', new.raw_app_meta_data ->> 'provider',
15 'email_confirmed', new.email_confirmed_at is not null
16 );
17 elsif TG_OP = 'UPDATE' then
18 if old.last_sign_in_at is distinct from new.last_sign_in_at then
19 event_name := 'USER_SIGN_IN';
20 elsif old.email_confirmed_at is null and new.email_confirmed_at is not null then
21 event_name := 'EMAIL_CONFIRMED';
22 elsif old.encrypted_password is distinct from new.encrypted_password then
23 event_name := 'PASSWORD_CHANGED';
24 else
25 event_name := 'USER_UPDATED';
26 end if;
27 event_meta := jsonb_build_object(
28 'email', new.email,
29 'provider', new.raw_app_meta_data ->> 'provider',
30 'last_sign_in', new.last_sign_in_at
31 );
32 end if;
33
34 insert into public.audit_log (user_id, event_type, metadata)
35 values (new.id, event_name, event_meta);
36
37 return new;
38end;
39$$;

Expected result: The trigger function is created and can detect signup, sign-in, email confirmation, password change, and generic user update events.

3

Attach the trigger to the auth.users table

Create a trigger that fires after every INSERT and UPDATE on auth.users. Use AFTER trigger (not BEFORE) because you want to log the event after it has been committed, not risk blocking the auth operation if the logging fails. The trigger calls the log_auth_event function for each affected row.

typescript
1create trigger on_auth_event
2 after insert or update on auth.users
3 for each row execute function public.log_auth_event();

Expected result: Every new signup and every sign-in, email confirmation, or password change automatically creates a row in the audit_log table.

4

Add client-side auth event tracking with onAuthStateChange

The database trigger captures server-side events, but client-side events like SIGNED_OUT and TOKEN_REFRESHED only happen in the browser. Use onAuthStateChange to listen for these events and optionally log them to the same audit_log table via an Edge Function or a direct insert. Set up the listener early in your application lifecycle, typically in your root component or layout.

typescript
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 process.env.NEXT_PUBLIC_SUPABASE_URL!,
5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
6)
7
8// Listen for auth state changes
9const { data: { subscription } } = supabase.auth.onAuthStateChange(
10 async (event, session) => {
11 console.log('Auth event:', event)
12
13 // Track client-side events via Edge Function
14 if (event === 'SIGNED_OUT' || event === 'TOKEN_REFRESHED') {
15 await supabase.functions.invoke('log-auth-event', {
16 body: {
17 event_type: event,
18 user_id: session?.user?.id ?? null,
19 timestamp: new Date().toISOString()
20 }
21 })
22 }
23 }
24)
25
26// Clean up on unmount
27// subscription.unsubscribe()

Expected result: Client-side events like SIGNED_OUT and TOKEN_REFRESHED are captured and sent to the backend for logging.

5

Query the audit log to analyze auth event history

Once events are being tracked, query the audit_log table to analyze user behavior. You can count signups per day, identify users who never confirmed their email, find accounts with frequent password changes, or detect suspicious sign-in patterns. Use the SQL Editor or the JS client for these queries.

typescript
1-- Signups per day for the last 30 days
2select
3 date_trunc('day', created_at) as day,
4 count(*) as signups
5from public.audit_log
6where event_type = 'USER_SIGNUP'
7 and created_at > now() - interval '30 days'
8group by day
9order by day desc;
10
11-- Users who signed up but never confirmed email
12select user_id, metadata ->> 'email' as email, created_at
13from public.audit_log
14where event_type = 'USER_SIGNUP'
15 and (metadata ->> 'email_confirmed')::boolean = false
16 and user_id not in (
17 select user_id from public.audit_log where event_type = 'EMAIL_CONFIRMED'
18 );
19
20-- Most recent events for a specific user (from JS client)
21const { data } = await supabase
22 .from('audit_log')
23 .select('event_type, metadata, created_at')
24 .order('created_at', { ascending: false })
25 .limit(20)

Expected result: Queries return auth event analytics such as daily signup counts, unconfirmed users, and individual user event timelines.

Complete working example

auth-event-tracking.sql
1-- Audit log table
2create table public.audit_log (
3 id bigint generated always as identity primary key,
4 user_id uuid not null,
5 event_type text not null,
6 metadata jsonb default '{}',
7 created_at timestamptz default now()
8);
9
10alter table public.audit_log enable row level security;
11
12create policy "Users read own audit logs"
13on public.audit_log for select to authenticated
14using ((select auth.uid()) = user_id);
15
16create index idx_audit_log_user_id on public.audit_log (user_id, created_at desc);
17create index idx_audit_log_event_type on public.audit_log (event_type, created_at desc);
18
19-- Trigger function to log auth events
20create or replace function public.log_auth_event()
21returns trigger
22language plpgsql
23security definer set search_path = ''
24as $$
25declare
26 event_name text;
27 event_meta jsonb;
28begin
29 if TG_OP = 'INSERT' then
30 event_name := 'USER_SIGNUP';
31 event_meta := jsonb_build_object(
32 'email', new.email,
33 'provider', new.raw_app_meta_data ->> 'provider',
34 'email_confirmed', new.email_confirmed_at is not null
35 );
36 elsif TG_OP = 'UPDATE' then
37 if old.last_sign_in_at is distinct from new.last_sign_in_at then
38 event_name := 'USER_SIGN_IN';
39 elsif old.email_confirmed_at is null and new.email_confirmed_at is not null then
40 event_name := 'EMAIL_CONFIRMED';
41 elsif old.encrypted_password is distinct from new.encrypted_password then
42 event_name := 'PASSWORD_CHANGED';
43 else
44 event_name := 'USER_UPDATED';
45 end if;
46 event_meta := jsonb_build_object(
47 'email', new.email,
48 'provider', new.raw_app_meta_data ->> 'provider',
49 'last_sign_in', new.last_sign_in_at
50 );
51 end if;
52
53 insert into public.audit_log (user_id, event_type, metadata)
54 values (new.id, event_name, event_meta);
55
56 return new;
57end;
58$$;
59
60-- Attach trigger to auth.users
61create trigger on_auth_event
62 after insert or update on auth.users
63 for each row execute function public.log_auth_event();
64
65-- Optional: retention cleanup (requires pg_cron extension)
66select cron.schedule(
67 'cleanup-old-audit-logs',
68 '0 3 * * 0',
69 $$delete from public.audit_log where created_at < now() - interval '90 days'$$
70);

Common mistakes when tracking Auth Events in Supabase

Why it's a problem: Using a BEFORE trigger instead of AFTER, which can block the auth operation if the logging function fails

How to avoid: Use AFTER INSERT OR UPDATE triggers for audit logging. This ensures the auth operation succeeds even if the logging fails.

Why it's a problem: Adding an INSERT RLS policy for authenticated users on the audit_log table, allowing users to insert fake events

How to avoid: Do not add an INSERT policy for authenticated users. The trigger function uses security definer to write events, bypassing RLS. Only add a SELECT policy for users to read their own events.

Why it's a problem: Not setting search_path = '' on the security definer function, creating a potential search path exploit

How to avoid: Always add SET search_path = '' to any function declared with SECURITY DEFINER, and use fully qualified table names (public.audit_log, not just audit_log).

Best practices

  • Use database triggers for server-side events and onAuthStateChange for client-side events to get complete coverage
  • Always use security definer with set search_path = '' for trigger functions that access the auth schema
  • Add indexes on user_id and created_at for fast audit log queries
  • Implement a retention policy to delete old audit log entries and prevent unbounded table growth
  • Store flexible metadata in a jsonb column so you can extend the tracked data without schema changes
  • Do not add INSERT RLS policies on the audit_log table — let only the trigger function write to it
  • Use AFTER triggers for audit logging so the primary operation is not blocked by logging failures
  • Clean up onAuthStateChange subscriptions when components unmount to prevent memory leaks

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I want to track all auth events in my Supabase project (signups, logins, email confirmations, password changes). Show me how to create a database trigger on auth.users that logs events to a custom audit_log table, and how to use onAuthStateChange for client-side events.

Supabase Prompt

Create a Supabase auth event tracking system with an audit_log table, a trigger function on auth.users that detects signup, sign-in, email confirmation, and password change events, and client-side onAuthStateChange tracking. Include SQL for the table, trigger, RLS policies, and analytics queries.

Frequently asked questions

Does the database trigger capture every login event?

Yes. Every successful sign-in updates the last_sign_in_at column on auth.users, which fires the UPDATE trigger. The trigger function detects this change and logs a USER_SIGN_IN event.

Can I track failed login attempts with a database trigger?

No. Failed login attempts do not modify auth.users, so the trigger does not fire. To track failed logins, use client-side error handling and send the failure event to an Edge Function or external logging service.

Will the trigger slow down the auth process?

The impact is minimal. The trigger performs a single INSERT into the audit_log table, which takes less than a millisecond. Using an AFTER trigger ensures the auth operation completes before logging starts.

How do I prevent the audit_log table from growing too large?

Schedule a pg_cron job to delete old records. For example: select cron.schedule('cleanup', '0 3 * * 0', 'delete from public.audit_log where created_at < now() - interval 90 days'). This runs every Sunday at 3 AM.

Can users see other users' audit events?

No. The RLS policy on audit_log restricts SELECT to rows where user_id matches auth.uid(). Each user can only see their own events.

What auth events does onAuthStateChange capture?

It captures INITIAL_SESSION, SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, USER_UPDATED, and PASSWORD_RECOVERY events. These are client-side only and complement the server-side trigger.

Can RapidDev help me build a custom auth event tracking and analytics system?

Yes. RapidDev can design and implement a complete auth event tracking pipeline including database triggers, client-side listeners, analytics dashboards, and alerting for suspicious activity.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.