<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[The Incident Log]]></title><description><![CDATA[The Incident Log]]></description><link>https://uduakukpong.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>The Incident Log</title><link>https://uduakukpong.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 12:22:36 GMT</lastBuildDate><atom:link href="https://uduakukpong.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Row-Level Security for Multi-Tenant Postgres: USING vs WITH CHECK in Practice]]></title><description><![CDATA[This piece isn't about turning RLS on. It's about what goes inside the policies once it is, and about proving they do what you think they do instead of just trusting the SQL.
That distinction matters ]]></description><link>https://uduakukpong.hashnode.dev/row-level-security-for-multi-tenant-postgres-using-vs-with-check-in-practice</link><guid isPermaLink="true">https://uduakukpong.hashnode.dev/row-level-security-for-multi-tenant-postgres-using-vs-with-check-in-practice</guid><category><![CDATA[database]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[postgres]]></category><category><![CDATA[Security]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[SQL]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Uduak Ukpong]]></dc:creator><pubDate>Tue, 15 Sep 2026 20:36:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa2fadedac83b574fecddfb/64612bb0-872b-4ee8-b9c7-4dc72e0d9005.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This piece isn't about turning RLS on. It's about what goes inside the policies once it is, and about proving they do what you think they do instead of just trusting the SQL.</p>
<p>That distinction matters because a bad RLS policy compiles fine. Postgres accepts it without complaint. Nobody catches the mistake in code review, because reading a policy tells you what it's supposed to allow, not what it actually denies. The only way to know for sure is to attack it as a real user with real credentials, then read the output. Most of this article is that attack, run for real, against Atlas's actual schema.</p>
<h2>The tenant boundary in three tables</h2>
<p>The code throughout is real, pulled from Atlas, a project and task management app I built. Every table in it is scoped to a project, and the project is the tenant boundary. Real schema, from <code>supabase/migrations/001_initial_schema.sql</code>:</p>
<pre><code class="language-sql">create table public.projects (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  description text,
  status text default 'active' check (status in ('active', 'completed', 'archived')),
  owner_id uuid references public.profiles(id) on delete cascade not null,
  created_at timestamptz default now() not null
);

create table public.project_members (
  project_id uuid references public.projects(id) on delete cascade not null,
  user_id uuid references public.profiles(id) on delete cascade not null,
  role text default 'collaborator' check (role in ('owner', 'collaborator')),
  joined_at timestamptz default now() not null,
  primary key (project_id, user_id)
);

create table public.tasks (
  id uuid default gen_random_uuid() primary key,
  title text not null,
  description text,
  status text default 'todo' check (status in ('todo', 'in_progress', 'done')),
  project_id uuid references public.projects(id) on delete cascade not null,
  assignee_id uuid references public.profiles(id) on delete set null,
  created_at timestamptz default now() not null,
  due_date timestamptz
);
</code></pre>
<p>The tenant key is different on each table, and it's worth naming plainly. On <code>projects</code>, the tenant is the row itself. A project doesn't belong to another project. On <code>project_members</code> and <code>tasks</code>, the tenant key is <code>project_id</code>. Every policy in this piece exists to enforce one rule against that key: a user only touches rows whose <code>project_id</code> traces back to a project they belong to.</p>
<p>RLS on these three tables was turned on in the same migration, explicitly, not by any later automation:</p>
<pre><code class="language-sql">alter table public.projects enable row level security;
alter table public.project_members enable row level security;
alter table public.tasks enable row level security;
</code></pre>
<h2>Reads filter, writes check, and that split lives in two clauses</h2>
<p>Every RLS policy attaches to one command, <code>SELECT</code>, <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code>, or <code>ALL</code>, and to a clause. <code>USING</code> is a filter. It decides, per row, whether a query can see that row at all. A row that fails <code>USING</code> on a <code>SELECT</code> doesn't error, it just isn't there. <code>WITH CHECK</code> is a gate on the row a write is about to leave behind. On <code>INSERT</code>, the new row has to pass it or the insert fails. On <code>UPDATE</code>, both clauses run: <code>USING</code> decides which existing rows can be targeted, and <code>WITH CHECK</code> decides whether the row's new state is still allowed to exist.</p>
<p>Here's <code>tasks</code>, current policies, one per command:</p>
<pre><code class="language-sql">create policy "tasks: project members can read"
  on public.tasks for select
  to authenticated
  using (
    is_active_user()
    and (
      exists (
        select 1 from public.project_members
        where project_id = tasks.project_id
        and user_id = auth.uid()
      ) or
      exists (
        select 1 from public.projects
        where id = tasks.project_id
        and owner_id = auth.uid()
      )
    )
  );

create policy "tasks: project members can create"
  on public.tasks for insert
  to authenticated
  with check (
    is_active_user()
    and (
      exists (
        select 1 from public.project_members
        where project_id = tasks.project_id
        and user_id = auth.uid()
      ) or
      exists (
        select 1 from public.projects
        where id = tasks.project_id
        and owner_id = auth.uid()
      )
    )
  );

create policy "tasks: project members can update"
  on public.tasks for update
  to authenticated
  using (
    is_active_user()
    and (
      exists (
        select 1 from public.project_members
        where project_id = tasks.project_id
        and user_id = auth.uid()
      ) or
      exists (
        select 1 from public.projects
        where id = tasks.project_id
        and owner_id = auth.uid()
      )
    )
  );

create policy "tasks: owner can delete"
  on public.tasks for delete
  to authenticated
  using (
    is_active_user()
    and exists (
      select 1 from public.projects
      where id = tasks.project_id
      and owner_id = auth.uid()
    )
  );
</code></pre>
<p>Ignore <code>is_active_user()</code> for now. It's a later account-deletion guard layered onto these policies, not part of the tenant-isolation story here.</p>
<p>Look at the update policy. It defines <code>USING</code>. It has no <code>WITH CHECK</code> at all. That's not a gap, it's Postgres's own default: when a policy needs a <code>WITH CHECK</code> and doesn't specify one, Postgres reuses the <code>USING</code> expression for both jobs. So <code>tasks: project members can update</code> runs the same membership check twice on a single <code>UPDATE</code>, once to decide which row a member is even allowed to select for editing, and again against the row's resulting state before the update is allowed to land. A member can't target a task outside their projects, and they can't use an update to move a task into someone else's project either. One expression, applied on both ends.</p>
<h2><code>is_project_member()</code> exists because the inline version caused recursion</h2>
<p>The <code>is_project_member()</code> function already showed up in the <a href="/blog/build-postgres-audit-log">audit-log piece</a>, used inside a trigger. Here it solves a different problem entirely.</p>
<p>The original <code>project_members</code> read policy, from migration 002, checked membership by querying <code>project_members</code> from inside its own policy:</p>
<pre><code class="language-sql">create policy "project_members: members can read"
  on public.project_members for select
  to authenticated
  using (
    exists (
      select 1 from public.project_members pm
      where pm.project_id = project_members.project_id
      and pm.user_id = auth.uid()
    )
  );
</code></pre>
<p>That's a policy on <code>project_members</code> that queries <code>project_members</code>. Postgres has to apply the table's RLS policy to evaluate the table's RLS policy. Infinite recursion. Migration 003 fixed it by moving the check into a function:</p>
<pre><code class="language-sql">CREATE OR REPLACE FUNCTION public.is_project_member(_user_id uuid, _project_id uuid)
 RETURNS boolean
 LANGUAGE plpgsql
 STABLE SECURITY DEFINER
 SET search_path TO 'public'
AS $function$
begin
  return exists (
    select 1 from public.project_members
    where project_id = _project_id
    and user_id = _user_id
  );
end;
$function$

create policy "project_members: members can read"
  on public.project_members for select
  to authenticated
  using (is_project_member(auth.uid(), project_id));
</code></pre>
<p><code>SECURITY DEFINER</code> is what actually breaks the loop. It makes the function run as its owner, not as the calling <code>authenticated</code> role, so the <code>SELECT</code> inside it doesn't re-trigger <code>project_members</code>'s RLS policy the way a plain inline subquery would. The check now happens from outside the policy chain instead of one more layer inside it.</p>
<h2><code>grant all</code> looks reckless until you know what's actually gating access</h2>
<p>The grants on all three tables, verbatim from migration 001:</p>
<pre><code class="language-sql">grant all on public.projects to authenticated;
grant all on public.project_members to authenticated;
grant all on public.tasks to authenticated;
</code></pre>
<p>Read on its own, that looks like every authenticated user can do anything to every row in these tables. At the grant level, that's true. In practice, it isn't, because grants and policies answer two different questions. The grant says which commands the <code>authenticated</code> role may attempt at all. The policy says which specific rows that attempt is allowed to touch. Postgres checks both, in order. <code>grant all</code> opens the door to trying an <code>UPDATE</code>. <code>USING</code> and <code>WITH CHECK</code> decide whether any row actually moves.</p>
<p>By contrast, <code>activity_log</code> takes one narrow <code>SELECT</code> grant, nothing else, because nothing but a <code>SECURITY DEFINER</code> trigger was ever meant to write to it. <code>projects</code>, <code>project_members</code>, and <code>tasks</code> are different tables with a different job. Real users insert, update, and delete their own rows directly, so the grant has to allow it. All of the isolation work moves onto the policies instead, on purpose.</p>
<h2>A denied read returns nothing, a denied write returns zero rows</h2>
<p>Postgres RLS denies reads by filtering, not erroring, a <code>SELECT</code> returns fewer rows, never a permission error for a correctly-denied row. So when you do see a permission error on a <code>SELECT</code>, it's a missing grant, not RLS doing its job.</p>
<p>The write side behaves differently, and it's worth being precise about it. A <code>SELECT</code> with a failing <code>USING</code> clause just comes back with fewer rows, or none, no error to catch. An <code>UPDATE</code> or <code>DELETE</code> with a failing <code>USING</code> clause looks the same on the surface, <code>UPDATE 0</code>, <code>DELETE 0</code>, no error. But a write can also go loud: if a row passes <code>USING</code> and gets selected for the update, and the resulting row then fails <code>WITH CHECK</code>, Postgres raises an actual error, <code>new row violates row-level security policy</code>. Reads can only go quiet. Writes can go quiet or loud, depending on which clause fails and when.</p>
<p>Know which one you're looking at before you debug it.</p>
<h2>User A cannot read, update, or delete User B's task, and the terminal proves it</h2>
<p>Setup: two real users on Atlas's local Supabase stack, connected with plain <code>psql</code>, no application code involved. <code>e2e-primary</code> owns a project and a task. <code>e2e-secondary</code> owns a separate project and a separate task. Each is authenticated by setting <code>request.jwt.claims</code> directly in the session, the same value Supabase's PostgREST layer would set from a real JWT.</p>
<p>As <code>e2e-primary</code>, against a task owned by <code>e2e-secondary</code>:</p>
<pre><code class="language-sql">set role authenticated;
select set_config('request.jwt.claims', json_build_object('sub', '3cb56877-7dfa-4dc9-ae26-9f48a5a95bfe', 'role', 'authenticated')::text, false);

select id, title, project_id from public.tasks where id = '66666666-6666-6666-6666-666666666666';
-- id | title | project_id
-- ----+-------+------------
-- (0 rows)

update public.tasks set title = 'HACKED BY A' where id = '66666666-6666-6666-6666-666666666666';
-- UPDATE 0

delete from public.tasks where id = '66666666-6666-6666-6666-666666666666';
-- DELETE 0

reset role;
</code></pre>
<p><code>UPDATE 0</code> on its own would be ambiguous. It looks identical to a <code>WHERE</code> clause that simply matched nothing, RLS or no RLS. The step that actually proves isolation is the read-back, as superuser, right after:</p>
<pre><code class="language-sql">select id, title, project_id from public.tasks where id = '66666666-6666-6666-6666-666666666666';

--                   id                  |               title               |              project_id
-- --------------------------------------+------------------------------------+--------------------------------------
--  66666666-6666-6666-6666-666666666666 | Adversarial Proof Task B (rerun)  | 55555555-5555-5555-5555-555555555555
-- (1 row)
</code></pre>
<p>The row is still there. Still owned by <code>e2e-secondary</code>'s project. Still holding its original title. Nothing moved.</p>
<p>Running the same three commands the other direction, <code>e2e-secondary</code> against <code>e2e-primary</code>'s task, produced the same shape of result: zero rows on the <code>SELECT</code>, <code>UPDATE 0</code>, <code>DELETE 0</code>, and a superuser read-back confirming <code>e2e-primary</code>'s task kept its original title. The denial isn't a one-way artifact of a single policy. It holds in both directions.</p>
<h2>Honest scope, and where this doesn't reach</h2>
<p>The proof you just ran is a point-in-time manual check, not a guarantee that holds over time. Nothing re-runs it, so a later policy change could break isolation silently and no test would notice. To close that, wire the same kind of adversarial check into CI with a tool like pgTAP or pg_prove, so a broken policy fails the build instead of waiting on a reviewer to spot it.</p>
<p>And the proof assumes what any RLS setup has to assume: a credential that bypasses RLS entirely, whether that's superuser, the service role, or a leaked connection string, sits outside this threat model. These policies gate the <code>authenticated</code> role going through the app's normal connection. They were never built to survive a stolen database password, and no policy design changes that.</p>
<p>If you're running RLS on a multi-tenant schema of your own, don't take a policy's word for it. Run the version of this test against your own tables, with two real users and real credentials, and read what actually comes back.</p>
]]></content:encoded></item><item><title><![CDATA[Build a Postgres Audit Log Your Application Code Cannot Bypass]]></title><description><![CDATA[Most audit logs get written by application code. A service function calls the database, then calls a logging function right after, two separate steps, two separate places to remember. Forget the secon]]></description><link>https://uduakukpong.hashnode.dev/build-a-postgres-audit-log-your-application-code-cannot-bypass</link><guid isPermaLink="true">https://uduakukpong.hashnode.dev/build-a-postgres-audit-log-your-application-code-cannot-bypass</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[postgres]]></category><category><![CDATA[database]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[audit logs]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Uduak Ukpong]]></dc:creator><pubDate>Sun, 13 Sep 2026 16:39:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa2fadedac83b574fecddfb/dcaa6c94-724b-49e5-ba13-40058fedfccf.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most audit logs get written by application code. A service function calls the database, then calls a logging function right after, two separate steps, two separate places to remember. Forget the second call in one code path, a new API route, an admin script, a one-off fix run straight from a database console, and that mutation happens with no record of it. Nothing breaks. No error. The gap just sits there undiscovered until the day you need history that isn't there.</p>
<p>The fix isn't a better logging library or a stricter code review checklist. It's moving the write itself out of application code entirely. If the database writes the log row as a direct consequence of the mutation, in the same transaction, there's no second step left for anyone to forget. The log becomes a property of the schema, not a habit every contributor has to maintain.</p>
<p>Here's how to build that, with Postgres triggers, using a real <code>activity_log</code> table and real trigger functions from Atlas, a project-management app I built.</p>
<h2>The table the whole pattern hangs off of</h2>
<pre><code class="language-sql">create table public.activity_log (
  id uuid primary key default gen_random_uuid(),
  project_id uuid not null references public.projects(id) on delete cascade,
  actor_id uuid references public.profiles(id) on delete set null,
  actor_name text not null,
  verb text not null check (verb in (
    'project_created', 'project_updated',
    'task_created', 'task_status_changed', 'task_updated', 'task_deleted',
    'member_added', 'member_removed'
  )),
  entity_type text not null check (entity_type in ('project', 'task', 'project_member')),
  entity_id uuid,
  entity_name text not null,
  metadata jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);

create index activity_log_project_id_created_at_idx
  on public.activity_log (project_id, created_at desc);
</code></pre>
<p>A few of these columns are doing more work than they look like.</p>
<p><code>actor_id</code> is nullable and set to null on delete, but <code>actor_name</code> is a plain, required text column. That pairing is deliberate. A log entry has to outlive the user it's about. Delete a profile later, offboarding, a right-to-erasure request, whatever, and the log keeps the "who" even after that foreign key is gone.</p>
<p><code>verb</code> is a closed enum, enforced with a <code>CHECK</code> constraint, not a generic insert or update or delete flag. That's what lets this read like an actual activity feed, <code>task_status_changed</code>, not a database changelog nobody wants to read.</p>
<p><code>entity_type</code>, <code>entity_id</code>, and <code>entity_name</code> together are a polymorphic pointer. One table logs history for three unrelated tables, projects, tasks, and project members, instead of three separate log tables.</p>
<p><code>metadata jsonb</code> is where the real diff lives. Its shape depends on which <code>verb</code> produced it, more on that shortly.</p>
<p>The index matches the one query this table serves: a project's own recent activity, newest first.</p>
<h2>The permission layer, not just the trigger</h2>
<p>None of that matters if the row that just wrote a log entry can also edit its own history afterward. This is the part that audit-log write-ups often skip, and it's the part that makes "cannot bypass" true instead of aspirational. Check what the application's own database role is allowed to do to this table:</p>
<pre><code class="language-sql">alter table public.activity_log enable row level security;

create policy "activity_log: members can view"
  on public.activity_log for select
  to authenticated
  using (is_project_member(auth.uid(), project_id));

grant select on public.activity_log to authenticated;
</code></pre>
<p>Worth naming up front: this is Supabase's Postgres, <code>authenticated</code> is the role every logged-in request connects as, and <code>auth.uid()</code> reads the user's id from the JWT claims PostgREST attaches to that connection, roughly a request-scoped role plus decoded session token on most other stacks.</p>
<p>There's one <code>SELECT</code> policy, scoped to project members, and no <code>INSERT</code>, <code>UPDATE</code>, or <code>DELETE</code> grant to <code>authenticated</code> anywhere in the migrations, the role the app itself connects as. Postgres denies every privilege by default unless it's explicitly granted. No grant for writing or altering a row means no path to do either. Not a weak path, no path at all.</p>
<p>Which raises the obvious question: if the app's own role can't <code>INSERT</code> into this table, how does a new row ever get written?</p>
<h2>The trigger function</h2>
<p><code>security definer</code>. Each function that writes to <code>activity_log</code> is declared with it. Here's the one attached to <code>tasks</code>, the most complete of the three:</p>
<pre><code class="language-sql">create or replace function public.handle_task_activity()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
  _actor uuid := auth.uid();
  _changes jsonb := '[]'::jsonb;
begin
  if tg_op = 'INSERT' then
    insert into public.activity_log
      (project_id, actor_id, actor_name, verb, entity_type, entity_id, entity_name, metadata)
    values
      (new.project_id, _actor, activity_actor_name(_actor), 'task_created', 'task', new.id, new.title, '{}'::jsonb);
    return new;
  end if;

  if tg_op = 'DELETE' then
    if exists (select 1 from public.projects where id = old.project_id) then
      insert into public.activity_log
        (project_id, actor_id, actor_name, verb, entity_type, entity_id, entity_name, metadata)
      values
        (old.project_id, _actor, activity_actor_name(_actor), 'task_deleted', 'task', old.id, old.title, '{}'::jsonb);
    end if;
    return old;
  end if;

  if new.status is distinct from old.status then
    insert into public.activity_log
      (project_id, actor_id, actor_name, verb, entity_type, entity_id, entity_name, metadata)
    values
      (new.project_id, _actor, activity_actor_name(_actor), 'task_status_changed', 'task', new.id, new.title,
       jsonb_build_object('from', old.status, 'to', new.status));
  end if;

  if new.title is distinct from old.title then
    _changes := _changes || jsonb_build_object('field', 'title', 'from', old.title, 'to', new.title);
  end if;
  if new.description is distinct from old.description then
    _changes := _changes || jsonb_build_object('field', 'description', 'from', old.description, 'to', new.description);
  end if;
  if new.due_date is distinct from old.due_date then
    _changes := _changes || jsonb_build_object('field', 'due_date', 'from', old.due_date, 'to', new.due_date);
  end if;

  if jsonb_array_length(_changes) &gt; 0 then
    insert into public.activity_log
      (project_id, actor_id, actor_name, verb, entity_type, entity_id, entity_name, metadata)
    values
      (new.project_id, _actor, activity_actor_name(_actor), 'task_updated', 'task', new.id, new.title, jsonb_build_object('changes', _changes));
  end if;
  return new;
end;
$$;
</code></pre>
<p>Take it piece by piece.</p>
<p><code>security definer</code> is the actual mechanism answering the question above. A function marked this way runs with its owner's privileges, typically a privileged migration role, not the caller's. The application's <code>authenticated</code> role triggers it with an ordinary <code>UPDATE</code> on <code>tasks</code>, a table it genuinely can write to. The function itself, running as its owner, is what reaches into <code>activity_log</code>. The app never touches that table directly, and doesn't need to.</p>
<p><code>_actor uuid := auth.uid()</code> captures the acting user server-side, from the session's JWT claim, when the trigger fires. It isn't passed in as an argument, or set by a request body. Whatever the database itself believes the caller's identity is, that's the actor. Nothing upstream of the database gets a vote in who's blamed for a change.</p>
<p>The <code>tg_op</code> branches come first and return early. <code>INSERT</code> logs a <code>task_created</code> row and stops. <code>DELETE</code> logs <code>task_deleted</code> and stops, guarded by an <code>exists</code> check, since a cascade delete can reach this trigger after the project itself is already gone. Everything below those two early returns only runs for an update.</p>
<p>Status changes get their own verb, <code>task_status_changed</code>, with its own two-field <code>metadata</code> shape, separate from the generic diff below. That's a deliberate modeling choice: status is the one field this schema treats as worth naming on its own, so the log captures how a task moves through its lifecycle, not just that it changed.</p>
<p>Everything else, title, description, due date, falls into the generic path: an <code>is distinct from</code> check per column, each appending a <code>{field, from, to}</code> object to a growing array, only inserted if non-empty. Worth being honest about the shape: it's four hand-written comparisons, not a loop over every column. Add a fifth trackable field to <code>tasks</code>, and it needs a fifth comparison here too, or it changes silently. The pattern guarantees no mutation skips logging, since every branch either returns early or falls through to this check. It doesn't guarantee every column change reaches the diff. That still depends on the function knowing the column exists.</p>
<h2>Wiring it up</h2>
<p>The function above does nothing until something calls it:</p>
<pre><code class="language-sql">create trigger on_task_created_activity
  after insert on public.tasks
  for each row execute function public.handle_task_activity();

create trigger on_task_updated_activity
  after update on public.tasks
  for each row execute function public.handle_task_activity();

create trigger on_task_deleted_activity
  after delete on public.tasks
  for each row execute function public.handle_task_activity();
</code></pre>
<p>Three separate statements, one per event, all pointing at the same function. The <code>tg_op</code> branching inside tells them apart at runtime. <code>AFTER</code>, not <code>BEFORE</code>: the row already exists in the transaction, recording something that already happened, not something still pending. <code>FOR EACH ROW</code>, not <code>FOR EACH STATEMENT</code>: an update touching ten tasks fires this ten times, since the diff is per row, not per statement.</p>
<h2>Proving it, against something that isn't the app</h2>
<p>Everything above is a claim until it's tested against something other than the application. Here's a real <code>psql</code> session, connected directly to Atlas's database. No app code in this transcript.</p>
<p>A task that actually exists, currently <code>todo</code>:</p>
<pre><code class="language-plaintext">select status from public.tasks where id = '7be1a49e-4002-4070-adb1-5053d4ea2645';
 status
--------
 todo
</code></pre>
<p>Set the session to look like the app's own role and a real logged-in user. One gotcha worth knowing first: <code>set_config</code>'s third argument has to be <code>false</code>, not <code>true</code>, or the JWT claim only lasts one statement. Every query after it silently loses the session, <code>auth.uid()</code> goes back to null, and the write that follows just matches zero rows, no error, nothing to notice.</p>
<pre><code class="language-plaintext">SET ROLE authenticated;
select set_config('request.jwt.claims',
  '{"sub":"6e9c97b7-6219-4905-bc66-9e9901261de9","role":"authenticated"}', false);
</code></pre>
<p>A direct update, no application involved:</p>
<pre><code class="language-plaintext">update public.tasks
set status = 'in_progress'
where id = '7be1a49e-4002-4070-adb1-5053d4ea2645';
UPDATE 1
</code></pre>
<p>Check <code>activity_log</code> for that project:</p>
<pre><code class="language-plaintext">select verb, entity_name, metadata, created_at
from public.activity_log
where project_id = 'a0e8d23e-fc6a-45aa-928c-c69df965a479'
order by created_at desc limit 1;

         verb          | entity_name |               metadata                |          created_at
------------------------+-------------+----------------------------------------+-------------------------------
 task_status_changed    | E2E task    | {"to": "in_progress", "from": "todo"} | 2026-09-12 11:36:10.428584+00
</code></pre>
<p>A row appeared. Nobody called a logging function. Nobody imported an audit module. The <code>UPDATE</code> itself produced it, because the trigger fires on the operation, not on whichever code path performed it. A raw <code>DELETE</code> against the same task produces the same result, a <code>task_deleted</code> row, same mechanism, nowhere to have skipped it.</p>
<p>That's one half of the claim: the mutation can't happen without the log row appearing. The other half is sharper. Can the log itself be edited afterward, to cover up what happened?</p>
<pre><code class="language-plaintext">update public.activity_log
set metadata = '{"tampered": true}'::jsonb
where id = '41ad0934-3e0a-4ea6-9e9a-318957332fb4';

ERROR:  permission denied for table activity_log
HINT:  Grant the required privileges to the current role with: GRANT UPDATE ON public.activity_log TO authenticated;
</code></pre>
<pre><code class="language-plaintext">delete from public.activity_log
where id = '41ad0934-3e0a-4ea6-9e9a-318957332fb4';

ERROR:  permission denied for table activity_log
HINT:  Grant the required privileges to the current role with: GRANT DELETE ON public.activity_log TO authenticated;
</code></pre>
<p>Same role, same active session, same table, and Postgres refuses outright. Not a silently filtered zero rows, a hard permission error, because the grant that would make either statement legal doesn't exist. The claim, demonstrated twice: the application's own role can produce the log, and cannot touch it once it exists.</p>
<h2>What this costs, and what it doesn't guarantee</h2>
<p>Every mutation to a tracked table now runs an extra function, on every insert, update, and delete, in the same transaction. For a handful of tables in a project-management app, that overhead doesn't register. For a table taking write load in the thousands per second, measure it first, not something to wave off as "just a trigger."</p>
<p>One more scoping note: <code>authenticated</code> also holds <code>TRUNCATE</code> by Supabase default, but PostgREST has no REST verb mapping to it, so nothing in the app's real surface can reach it. Revoking it protects against nothing, since reaching it needs the database password directly, and anyone holding that can just re-grant it or drop the table outright. The claim here is about the application's write path, not surviving a compromised credential, which no schema defends against.</p>
<p><code>security definer</code> earns the trust it's given by staying boring. It runs with elevated privilege, so the discipline that matters is keeping its logic fixed and hardcoded, no dynamic SQL a caller could influence. Before trusting this pattern in your own schema, audit every <code>security definer</code> function in it, not just the trigger you just wrote:</p>
<pre><code class="language-sql">select p.proname, p.prosecdef
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef = true;
</code></pre>
<p>Read every result's body for <code>EXECUTE</code>, <code>format()</code>, or any string-built query. The functions this pattern adds, the three <code>handle_*_activity</code> triggers and one small actor-name helper, are each a handful of lines of fixed logic, no dynamic SQL, no string concatenation, nothing built from caller input. That's what makes the elevated privilege safe here. Don't assume it holds elsewhere. Check each function on its own.</p>
<p>The per-column comparisons are an ongoing maintenance cost, not a one-time setup step: a new trackable column needs its own comparison added, or its changes go uncaptured.</p>
<p><code>project_id</code> also uses <code>on delete cascade</code>, deliberate here since a deleted project leaves no feed to show. To make the log outlive the project instead, switch that FK to <code>on delete set null</code>, like <code>actor_id</code>, or drop it, rather than just removing the cascade, which would only fail the project delete on a foreign-key violation.</p>
<p>Skip this if the data doesn't need it. A trusted internal tool with no compliance requirement, and no real cost to a missed entry, doesn't need a function running extra on every write. Reach for this when the log has to survive the application being wrong, buggy, or bypassed, not just a developer remembering to call a function.</p>
]]></content:encoded></item><item><title><![CDATA[Stop Using a mounted Flag to Fix Theme Flash: Use useSyncExternalStore Instead]]></title><description><![CDATA[The login page went blank in production. Not a broken layout, not a missing stylesheet. Blank: the whole subtree that the sidebar belonged to just disappeared from the DOM right after the page finishe]]></description><link>https://uduakukpong.hashnode.dev/stop-using-a-mounted-flag-to-fix-theme-flash-use-usesyncexternalstore-instead</link><guid isPermaLink="true">https://uduakukpong.hashnode.dev/stop-using-a-mounted-flag-to-fix-theme-flash-use-usesyncexternalstore-instead</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><category><![CDATA[Frontend Development]]></category><category><![CDATA[software development]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Uduak Ukpong]]></dc:creator><pubDate>Thu, 10 Sep 2026 19:30:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa2fadedac83b574fecddfb/69250cf8-3291-4e9d-9418-fb537953aafd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The login page went blank in production. Not a broken layout, not a missing stylesheet. Blank: the whole subtree that the sidebar belonged to just disappeared from the DOM right after the page finished loading. It only happened on a real production build (<code>next build &amp;&amp; next start</code>), never in <code>next dev</code>, and only when the stored theme preference was <code>"dark"</code>.</p>
<p>The cause was a theme toggle.</p>
<p>If you've worked with the Next.js App Router, you already know the usual shape of a dark-mode toggle: some client state holding <code>"light"</code> or <code>"dark"</code>, an effect that writes it onto the DOM, a button that flips it. You've probably also met the classic symptom that comes with getting this wrong: a flash of the wrong theme for a frame or two on load. What you might not know, since it's a narrower topic, is Trusted Types: a browser security feature that helps prevent DOM-based XSS by blocking untrusted strings from being written into dangerous sinks like <code>innerHTML</code>, unless an approved policy sanctions them. If a Trusted Types policy is enforced and something tries to write raw HTML through a path the policy didn't approve, the browser throws instead of rendering it. Keep that in your pocket. It's the reason this bug was a crash and not just a flicker.</p>
<p>This is Atlas, a project-management app I built solo (real Postgres row-level security, an append-only activity log written entirely by database triggers, accessibility treated as a requirement rather than a feature). It's not running at scale with real traffic. It doesn't need to be. Not for this story. What it has is a CI pipeline, a test suite, and one bug I had to actually diagnose, not simulate for a tutorial.</p>
<p>Here's what caused it, why the fix everyone reaches for first doesn't actually fix it, and what does.</p>
<h2>The disagreement under the flash</h2>
<p>"Flash of wrong theme" undersells what's happening. It's not really a timing problem. It's a disagreement. Next.js renders your app to HTML on the server, where there's no <code>window</code>, no <code>localStorage</code>, no way to know what a returning visitor picked last time. React then hydrates that HTML on the client, attaching event handlers and reconciling its idea of the tree against what's already in the DOM. If the client's first render produces different output than the server did, React has a mismatch on its hands.</p>
<p>In Atlas, that mismatch traced back to one function: the lazy initializer inside <code>ThemeContext</code>'s <code>useState</code> call.</p>
<pre><code class="language-tsx">const [theme, setTheme] = useState&lt;Theme&gt;(() =&gt; {
  if (typeof window === "undefined") return "light";

  const stored = localStorage.getItem("atlas-theme");
  if (stored === "light" || stored === "dark") return stored;

  // Defer to system preference if no stored theme, and persist that choice.
  const system = window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";

  localStorage.setItem("atlas-theme", system);

  return system;
});
</code></pre>
<p>Read that <code>typeof window</code> check carefully, because the bug is hiding in what it doesn't guard. On the server, <code>window</code> is genuinely undefined, so this returns <code>"light"</code>, always. But on the client, this initializer doesn't just run once at some safe point after mount. It runs during React's very first client render, the hydration render, and by then <code>window</code> already exists. So the client's first render skips the <code>"light"</code> fallback entirely and reads <code>localStorage</code> immediately, returning whatever the user actually had stored.</p>
<p>Server HTML gets built from the forced <code>"light"</code> branch. But on the client, that first render reads whatever was actually stored. If your stored theme was <code>"dark"</code>, the two disagree on the very first paint, before React has done anything else.</p>
<h2>Why it was a crash, not a flicker</h2>
<p>Most of the time, a hydration mismatch is just visually annoying: React discards the mismatched subtree and regenerates it client-side, and you get a flash. In Atlas, <code>Sidebar.tsx</code> was the one component whose <em>render output</em> actually branched on <code>theme</code> (the icon, the label, the button), so it was the one place this showed up at all.</p>
<p>In React, when that client regeneration reaches an inline script, React recreates the script element by first setting a raw <code>&lt;script&gt;</code> string through a temporary div's <code>innerHTML</code>. Atlas runs a Trusted Types policy through a script in <code>app/layout.tsx</code> that only defines <code>createScriptURL</code> (needed for the app's own chunk loader), not <code>createHTML</code>. Recovery hit a browser API it wasn't authorized to use, and threw. In production, with the policy actually enforced, that meant recovery failed. The failure took the rest of the screen with it. It just stayed blank. I'm not going to get into the design of that Trusted Types policy here. That's a separate topic. What matters for this piece is the consequence: a hydration mismatch that would be a cosmetic flash almost anywhere else was a blank screen in production.</p>
<h2>The tempting fix, and where it actually falls short</h2>
<p>The standard answer to "my render depends on something the server can't know" is a <code>mounted</code> flag. It's not from Atlas. It's just the shape most of us reach for:</p>
<pre><code class="language-tsx">function ThemeIcon({ theme }: { theme: 'light' | 'dark' }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() =&gt; {
    setMounted(true);
  }, []);

  if (!mounted) {
    return &lt;IconPlaceholder /&gt;;
  }

  return theme === 'light' ? &lt;Moon /&gt; : &lt;Sun /&gt;;
}
</code></pre>
<p>Give this a fair hearing, because it does solve the mismatch. Server and client both render the placeholder on the first pass, since <code>mounted</code> starts <code>false</code> in both places. No disagreement, no crash. The real icon only shows up once <code>useEffect</code> fires after mount, which is client-only by definition.</p>
<p>Here's where it stops being a fix and starts being a workaround. It always requires a post-mount render before showing the real value, even when there is no hydration mismatch to avoid. It trades a wrong-icon flash for a placeholder-then-pop-in flash (better, but still a flash). Also, it doesn't touch the actual problem: React still treats its own state as the source of truth. The flag is a manual "don't trust yourself yet" gate bolted onto the read side, and you have to remember to bolt it on again at every component that needs the theme. Extract that pattern into a shared hook, which you inevitably will once you have two or three call sites, and you're one small step from useSyncExternalStore anyway, just without the guarantee it actually gives you.</p>
<p>The deeper issue: <code>theme</code> was never really React's state to own in the first place.</p>
<h2>Where the theme actually lives</h2>
<p>By the time React starts hydrating anything, the correct theme is already sitting on the DOM. Atlas sets it with an inline script in the root layout, written as a raw <code>&lt;script&gt;</code> tag via <code>dangerouslySetInnerHTML</code> (not <code>next/script</code>, which would load too late to beat the paint):</p>
<pre><code class="language-js">(function(){try{var t=localStorage.getItem('atlas-theme');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-theme',t);}else{var d=window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.setAttribute('data-theme',d?'dark':'light');}}catch(e){}})();
</code></pre>
<p>It reads the stored preference, falls back to <code>matchMedia</code> if there isn't one, and sets <code>data-theme</code> on <code>document.documentElement</code>. The whole thing is wrapped in a <code>try/catch</code>, so a blocked <code>localStorage</code> (private browsing, strict cookie settings) just skips silently instead of breaking the page. This runs before React hydrates anything. <code>data-theme</code> is correct on the <code>&lt;html&gt;</code> element from the first paint.</p>
<p>React just doesn't know that. Nothing has told it. And that's the actual bug: not when the value becomes available, but which system owns it. The theme lives in the DOM. React needs to <em>read</em> an external source, not maintain its own parallel copy of the same fact.</p>
<p>That's exactly what <code>useSyncExternalStore</code> is for.</p>
<h2>The implementation</h2>
<pre><code class="language-typescript">"use client";

import { useSyncExternalStore } from "react";

export type DisplayedTheme = "light" | "dark" | "pending";

function subscribe(callback: () =&gt; void) {
  const observer = new MutationObserver(callback);
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["data-theme"],
  });
  return () =&gt; observer.disconnect();
}

function getSnapshot(): DisplayedTheme {
  return document.documentElement.getAttribute("data-theme") === "dark"
    ? "dark"
    : "light";
}

function getServerSnapshot(): DisplayedTheme {
  return "pending";
}

export function useDisplayedTheme(): DisplayedTheme {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
</code></pre>
<p>Three functions, each doing one job. <code>getSnapshot</code> doesn't hold state. It reads <code>data-theme</code> straight off <code>documentElement</code>, which means it's always reporting what's actually there, not what React last remembered setting. <code>subscribe</code> sets up a <code>MutationObserver</code> scoped tightly with <code>attributeFilter: ["data-theme"]</code>, so it only fires when that one attribute changes, not on every DOM mutation in the document. And <code>getServerSnapshot</code> is where the interesting decision lives, but I'll get to that in a moment.</p>
<p>Notice what <code>useDisplayedTheme</code> doesn't do: it doesn't write anything. Atlas keeps write and read on two separate paths on purpose. <code>ThemeContext</code> still owns the toggle:</p>
<pre><code class="language-tsx">function toggleTheme() {
  const next: Theme = theme === "light" ? "dark" : "light";
  setTheme(next);

  localStorage.setItem("atlas-theme", next);
}
</code></pre>
<p>Clicking the button updates React's own <code>theme</code> state and writes the choice to <code>localStorage</code>, both inside <code>toggleTheme</code> itself. A <code>useEffect</code> elsewhere in <code>ThemeContext</code> then reflects that updated state onto <code>data-theme</code>. But <code>Sidebar.tsx</code>, the component that actually renders based on the theme, doesn't read <code>theme</code> from that context at all anymore. It reads <code>useDisplayedTheme()</code>:</p>
<pre><code class="language-tsx">const { toggleTheme } = useTheme();
const displayedTheme = useDisplayedTheme();
const isThemePending = displayedTheme === "pending";
</code></pre>
<p>Write goes through the context's state, because a click handler needs somewhere to hold intent. Read goes through the DOM, because the DOM is what's actually true, including on that first render where React's own state hasn't caught up yet. Two different sources, chosen for two different jobs.</p>
<h2>The honest third state</h2>
<p><code>getServerSnapshot</code> returning <code>"pending"</code> instead of guessing <code>"light"</code> is the detail that makes the rest of this actually work, not just look tidy.</p>
<p>During hydration, <code>useSyncExternalStore</code> calls <code>getServerSnapshot</code> and only <code>getServerSnapshot</code>. It never touches <code>getSnapshot</code> on that first pass. So the server renders <code>"pending"</code>, and the client's first render also produces <code>"pending"</code>, by construction, since both sides call the exact same function. There's no way for these two to disagree, because they're not two guesses that happen to agree. They're the same fixed answer, sourced from the same place, every time. No mismatch, no recovery path, no <code>innerHTML</code> write for Trusted Types to reject.</p>
<p>The real theme shows up afterward, once the observer's <code>getSnapshot</code> takes over, and that's an ordinary re-render triggered by a normal state update, not an error being recovered from. <code>Sidebar.tsx</code> uses <code>isThemePending</code> to make that in-between moment honest instead of invisible:</p>
<pre><code class="language-tsx">&lt;button
  onClick={toggleTheme}
  disabled={isThemePending}
  aria-busy={isThemePending}
  aria-label={
    isThemePending
      ? "Loading theme preference"
      : displayedTheme === "light"
        ? "Switch to dark mode"
        : "Switch to light mode"
  }
&gt;
</code></pre>
<p>A <code>mounted</code> flag's placeholder state is a UI trick, something to look at while the real thing loads. <code>"pending"</code> is a real value in the type (<code>DisplayedTheme = "light" | "dark" | "pending"</code>), one the button's <code>disabled</code> and <code>aria-busy</code> attributes respond to directly. The type system knows there are three possible states here, not two, so nothing downstream can quietly pretend the answer is always known.</p>
<h2>What this costs, and when it isn't worth it</h2>
<p>There was a cleaner option on the table: read the theme from a cookie on the server and pass it down, which removes even the brief "pending" window entirely. It got rejected. <code>cookies()</code> unconditionally opts a route into dynamic rendering, and several of Atlas's routes are static today. Giving that up wasn't worth it. It would mean restructuring the dashboard layout out of being a single client component, just to remove a sub-100ms loading state. That's a call that could reasonably go the other way in a different app. It's not a universal rule, just the one that fit here.</p>
<p>More broadly, none of this is worth reaching for unless two things are both true: your app has state set on the DOM before React hydrates, and something's render output actually branches on that state. If your theme only drives CSS custom properties and nothing in your JSX conditionally renders based on it, there's no mismatch to have in the first place. <code>data-theme</code> can just sit there and your styles read it, no hook required.</p>
<p>The mainstream approach is to let the theme drive CSS (a class or data attribute on the html element that Tailwind or your stylesheet reads) and keep your JSX theme-agnostic. Most theme-dependent UI, including a toggle icon, is better handled that way, often by rendering both states and swapping them with CSS. I branched on the theme in JavaScript for one reason: the toggle's aria-label ("Switch to dark mode" versus "Switch to light mode") is an accessible name, and you cannot set an accessible name from a CSS class. Accessibility was a first-class requirement in Atlas, not a coat of paint, so the toggle had to announce its actual state to assistive tech. Once one thing in that component genuinely needed the theme as a JavaScript value, reading it correctly was the real problem, and that is what <code>useSyncExternalStore</code> solves. <code>Sidebar.tsx</code> needed this because it was the one place in Atlas where a component's actual output, not just its appearance, depended on a value React didn't have yet. That's the specific condition to look for, not "I have a theme toggle."</p>
]]></content:encoded></item></channel></rss>