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
Create the audit_log table for storing auth events
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.
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);89alter table public.audit_log enable row level security;1011-- Users can read their own audit events12create policy "Users read own audit logs"13on public.audit_log for select to authenticated14using ((select auth.uid()) = user_id);1516-- Create an index for fast lookups by user and time17create 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.
Create a trigger function to log auth.users changes
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.
1create or replace function public.log_auth_event()2returns trigger3language plpgsql4security definer set search_path = ''5as $$6declare7 event_name text;8 event_meta jsonb;9begin10 if TG_OP = 'INSERT' then11 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 null16 );17 elsif TG_OP = 'UPDATE' then18 if old.last_sign_in_at is distinct from new.last_sign_in_at then19 event_name := 'USER_SIGN_IN';20 elsif old.email_confirmed_at is null and new.email_confirmed_at is not null then21 event_name := 'EMAIL_CONFIRMED';22 elsif old.encrypted_password is distinct from new.encrypted_password then23 event_name := 'PASSWORD_CHANGED';24 else25 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_at31 );32 end if;3334 insert into public.audit_log (user_id, event_type, metadata)35 values (new.id, event_name, event_meta);3637 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.
Attach the trigger to the auth.users table
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.
1create trigger on_auth_event2 after insert or update on auth.users3 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.
Add client-side auth event tracking with onAuthStateChange
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.
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 process.env.NEXT_PUBLIC_SUPABASE_URL!,5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!6)78// Listen for auth state changes9const { data: { subscription } } = supabase.auth.onAuthStateChange(10 async (event, session) => {11 console.log('Auth event:', event)1213 // Track client-side events via Edge Function14 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)2526// Clean up on unmount27// subscription.unsubscribe()Expected result: Client-side events like SIGNED_OUT and TOKEN_REFRESHED are captured and sent to the backend for logging.
Query the audit log to analyze auth event history
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.
1-- Signups per day for the last 30 days2select3 date_trunc('day', created_at) as day,4 count(*) as signups5from public.audit_log6where event_type = 'USER_SIGNUP'7 and created_at > now() - interval '30 days'8group by day9order by day desc;1011-- Users who signed up but never confirmed email12select user_id, metadata ->> 'email' as email, created_at13from public.audit_log14where event_type = 'USER_SIGNUP'15 and (metadata ->> 'email_confirmed')::boolean = false16 and user_id not in (17 select user_id from public.audit_log where event_type = 'EMAIL_CONFIRMED'18 );1920-- Most recent events for a specific user (from JS client)21const { data } = await supabase22 .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
1-- Audit log table2create 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);910alter table public.audit_log enable row level security;1112create policy "Users read own audit logs"13on public.audit_log for select to authenticated14using ((select auth.uid()) = user_id);1516create 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);1819-- Trigger function to log auth events20create or replace function public.log_auth_event()21returns trigger22language plpgsql23security definer set search_path = ''24as $$25declare26 event_name text;27 event_meta jsonb;28begin29 if TG_OP = 'INSERT' then30 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 null35 );36 elsif TG_OP = 'UPDATE' then37 if old.last_sign_in_at is distinct from new.last_sign_in_at then38 event_name := 'USER_SIGN_IN';39 elsif old.email_confirmed_at is null and new.email_confirmed_at is not null then40 event_name := 'EMAIL_CONFIRMED';41 elsif old.encrypted_password is distinct from new.encrypted_password then42 event_name := 'PASSWORD_CHANGED';43 else44 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_at50 );51 end if;5253 insert into public.audit_log (user_id, event_type, metadata)54 values (new.id, event_name, event_meta);5556 return new;57end;58$$;5960-- Attach trigger to auth.users61create trigger on_auth_event62 after insert or update on auth.users63 for each row execute function public.log_auth_event();6465-- 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation