跳到正文
DEV Community· Tomo·· 3 小時前AI 評分59

Supabase 個人登入與審計追蹤實作:Auth、RLS、Triggers、防竄改測試與小型支援入口

Per-User Logins and an Audit Trail in Supabase: Auth, RLS and Triggers, a Tamper Drill, and a Small Support Portal

AI 導讀

作者以 2026-10-27 的 Supabase Free 專案示範個人登入、RLS、資料庫觸發器審計日誌,以及小型支援入口,並進行篡改測試。測試顯示,共享帳號的操作無法追溯到個人;個人帳號的日誌可記錄操作者,偽造 updated_by 會被覆寫,而對 activity_log 的新增、修改和刪除均遭 403 拒絕。

正文

If your team shares one login, an audit log can't tell you who did what. I wanted to see that for myself, then build the fix with Supabase Auth, RLS and database triggers, and try to break it. Everything is from my run on 2026-10-27 on a Free project. Prices change, so every number is dated.

How this was made: I wrote this with Claude's help: it drafted, and I ran every step, edited the text, and checked the results. The mistakes are mine. This is a personal learning project, not an official guide, and I don't work for Supabase.

The non-technical version (a shared key erases "who"): https://medium.com/p/125c58406e79
The code: https://github.com/tomozilla/audit-trail-portal-en-demo

What I did and did not do

  • Did: two tables and two triggers on a Free project, three fictional demo users, and a Python drill that signs in as each, makes changes, then tries to tamper. $0. I used only the publishable key, never a secret or service_role key.
  • Did not: test the service_role key (it bypasses RLS), organization-level audit logs, the contents of Auth logs, anonymous sign-ins, MFA, or many users.
  • Numbers come from the pricing page of 2026-10-27. Emails are fictional (example.com).

Shared login vs one login per person

Two boxes: a shared login shows one name in the history; one login per person shows who.

With a shared login, every line in the log has the same name. And to lock out someone who left, you change the password and lock out everyone.

The design

Three steps: a change on screen, a trigger records it, a read-only log table.

Two tables: support_requests (the work) and activity_log (who did what). Three ideas:

  1. A trigger writes the log, not the page. Code in the page can be skipped, edited or forged.
  2. "Who" comes from the login: auth.uid() and auth.jwt() ->> 'email' inside the trigger. Nothing the page sends is trusted.
  3. The log is read-only for the API: there is no insert, update or delete policy, and I also revoked those privileges.
-- stamp who and when on the row itself: the client can't claim to be someone else
create function public.stamp_request() returns trigger language plpgsql set search_path = '' as $$
begin new.updated_by := auth.uid(); new.updated_at := now(); return new; end $$;

-- write the log; security definer = runs with the owner's rights, so it can write when the caller can't
create function public.log_request_change() returns trigger language plpgsql security definer set search_path = '' as $$
begin
  insert into public.activity_log (actor, actor_email, action, request_id, detail)
  values (auth.uid(), auth.jwt() ->> 'email', tg_op, coalesce(new.id, old.id), ...);
  return coalesce(new, old);
end $$;
revoke insert, update, delete on public.activity_log from anon, authenticated;

A security definer function is powerful, so I set search_path = '' and revoked execute rights from the API roles. The full file is sql/01_requests_and_log.sql.

The drill

I created three users under Authentication -> Users -> Add user -> Create new user (Auto confirm), with random passwords kept only on my machine:

The Users list: shared-desk, staff-sato, staff-tanaka and a few older demo users, all fictional example.com addresses.

  • Case A: one shared login (shared-desk) used by two real people.
  • Case B: each person uses their own login (staff-sato, staff-tanaka).

scripts/drill.py plays both cases using the publishable key and prints status codes only. Here is the log afterwards:

The activity log: rows 1 and 2 are shared-desk; rows 3 to 5 show sato, tanaka and sato; row 6 is a no-op update by sato.

  • Rows 1 and 2 (Case A): whichever person acted, the log says shared-desk@example.com.
  • Rows 3 to 5 (Case B): sato added the request, tanaka moved it to in progress, sato marked it done.

Can the log be tampered with?

As staff-sato, I tried:

Attempt Result
Write tanaka's id into updated_by accepted (200), but stored as sato's id
Edit rows in activity_log 403 permission denied
Delete rows in activity_log 403
Insert a forged row into activity_log 403
Read requests without signing in 200 with 0 rows (RLS filters, no error)
Add a request without signing in 401

Stumble: the error message gave me a dangerous hint

The 403 body says: Grant the required privileges to the current role with: GRANT UPDATE ON public.activity_log TO authenticated;. Running that would remove the protection. I now treat error hints as suggestions to check against my intent, not commands.

Stumble: row 6

Row 6 is done -> done. It is my updated_by forgery attempt, recorded as an update that changed no status. A "nothing changed" update still gets logged, which is useful: the attempt is visible.

Two doors

Two boxes: the company's door (the console, with roles) and the app's door (your users).

Everything above is the app's door: the people who use your app. The console's door is a different list: organization members.

The invite dialog: Owner, Administrator and Developer roles, and a banner saying SSO is available on the Team plan and above.

From the pricing page (2026-10-27): Free and Pro offer Owner, Admin and Developer; Read-only and project-scoped roles and dashboard SSO start at Team. The retention of Auth audit logs is 1 hour on Free, 7 days on Pro, 28 days on Team (I did not look at their contents).

The portal page

I wrapped it in a small page: sign in as yourself, add and update requests, and read the log.

The sign-in screen.

Architecture: browser, database with RLS and a trigger, the activity log, and Auth proving who you are.

It loads supabase-js from a CDN (version pinned to 2.118.0) and reads a git-ignored config.js with the URL and publishable key.

cd app && cp config.example.js config.js   # fill in your values
python3 -m http.server 8000                # open http://localhost:8000/
node test.js                               # 13 checks

A wrong password shows a plain message and reveals nothing:

Sign-in with a wrong password: "Invalid login credentials", and no requests or log are visible.

After signing in as sato and adding a request:

The portal: requests on the left; the log on the right, with a red warning on the shared-desk rows and a badge saying 2 entries cannot be traced to a person.

Rows from the shared account get a red warning, and a badge counts the entries you cannot trace to a person. Seeing that number is more persuasive in a meeting than any explanation.

More stumbles

  • A hidden panel that wasn't hidden. Before signing in, the empty Requests and Log panels were visible. I had set the hidden attribute, but my CSS said display: grid, and the CSS won. Fix: [hidden] { display: none !important; }.
  • A "success" that changed nothing. An update that matches zero rows does not raise an error. The page checks the number of rows returned and says "Nothing changed" instead. I coded this but did not capture or reproduce that screen.
  • Request #3 is missing. The ids go 1, 2, 4. I believe the refused anonymous insert used up a sequence value, but I did not verify it.

Limits (the honest part)

  • Any signed-in user can change or delete any request (the policies are deliberately broad). Narrow them before real use.
  • A log is after the fact. It does not prevent anything.
  • Shared-account detection is only a list of emails you write down yourself.
  • Not tested: service_role (it bypasses RLS), phone width, MFA, many users.

What it costs

  • This walkthrough and the page: $0 (Free project, 3 users).
  • Monthly active users: 50,000 included on Free and 100,000 on Pro, then $0.00325 per user (pricing page, 2026-10-27). The log table grows: plan retention and clean-up.
  • Nothing was checked against an invoice.

Cleanup

I dropped both tables and both functions (sql/99_cleanup.sql) and deleted the three demo users.

If I took this further

Not tried: policies that let a person change only their own requests, a retention job for the log, and a leaver checklist that disables the account first.

Takeaways

  • A shared login erases "who": one login per person.
  • Write the log in the database, with "who" taken from the login, and make it read-only for the API.
  • Don't run an error's suggested GRANT without checking what it undoes.
  • Console members and app users are separate lists.
  • A log is after the fact. Also decide who may change what.

This closes the series for now. The next step is a one-page summary of plans, organizations, costs, backups and access.

來源:DEV Community · dev.to