Supabase content moderation: Edge Functions, Storage and RLS
A status column, row-level security, a database trigger and two Edge Functions. The SQL and both functions were run on 16 October 2026 against stand-ins for Supabase.
In Supabase, give each post or upload a status column that starts as pending, use row-level security so other users only see approved rows, and have an Edge Function send new content to a moderation API and update the status. For images, send a signed URL from a private Storage bucket that stays valid until the check runs. Results can come back in the same call or, at half the price, by webhook to a second Edge Function; this guide takes the webhook route.
How it fits together
| Step | Where | What happens |
|---|---|---|
| 1 | Your app | Inserts a post with only its body, or uploads a photo into its own folder and inserts a photos row with only its path. Both start as pending. |
| 2 | Postgres | An after insert trigger calls net.http_post. pg_net sends the request once the transaction commits. |
| 3 | Edge Function moderate | Checks the caller's secret key, reads the row, signs the photo's URL, queues an async check with metadata: { table, id } and stores the request's id. |
| 4 | Detectivision | Runs the check, then posts moderation.completed or moderation.failed to your endpoint. |
| 5 | Edge Function moderation-result | Verifies the signature, sets approved, review or rejected on rows still pending, and removes rejected files. |
| 6 | Your app | Reads posts and photos as usual. Row-level security returns approved rows plus the reader's own. |
The tables and their policies
Every row carries its own status, and only the server may change it.
create table public.posts (
id bigint generated always as identity primary key,
author_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
body text not null check (char_length(body) between 1 and 2000),
status text not null default 'pending'
check (status in ('pending', 'approved', 'review', 'rejected')),
request_id bigint, -- the Detectivision request, once queued
moderation jsonb, -- flags or error from the check
created_at timestamptz not null default now()
);
create table public.photos (
id bigint generated always as identity primary key,
owner_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
path text not null unique, -- object name in the private "photos" bucket
status text not null default 'pending'
check (status in ('pending', 'approved', 'review', 'rejected')),
request_id bigint,
moderation jsonb,
created_at timestamptz not null default now()
);
alter table public.posts enable row level security;
alter table public.photos enable row level security;
-- Clients send content, never a status: they may insert these columns and update nothing.
revoke all on table public.posts, public.photos from anon, authenticated;
grant select on table public.posts, public.photos to authenticated;
grant insert (body) on table public.posts to authenticated;
grant insert (path) on table public.photos to authenticated;
grant select, update on table public.posts, public.photos to service_role;
create policy "Approved posts, and your own" on public.posts
for select to authenticated
using (status = 'approved' or author_id = (select auth.uid()));
create policy "Post as yourself" on public.posts
for insert to authenticated
with check (author_id = (select auth.uid()));
create policy "Approved photos, and your own" on public.photos
for select to authenticated
using (status = 'approved' or owner_id = (select auth.uid()));
create policy "Photos from your own folder" on public.photos
for insert to authenticated
with check (owner_id = (select auth.uid()) and split_part(path, '/', 1) = (select auth.uid())::text);
- Grants decide what a client may write. Supabase warns: "Adding policies doesn't take those grants back." So the SQL revokes everything first. A signed-in user may then insert only
bodyorpath;statuskeeps its default,pending, and there is no update grant at all. In the SQL test, a client that tried to insert or update a status gotpermission denied. - Policies decide what a client may read. Supabase describes a policy as "adding a WHERE clause to every query". So
select * from postsreturns approved posts plus the reader's own, with no error and no filter in your code. - The
(select auth.uid())form andto authenticatedfollow Supabase's RLS guide: the policy is not evaluated for signed-out visitors at all. - The 2,000-character check on
bodyis a choice for this example. The API accepts texts of up to 10,000 characters (Text moderation).
A private bucket for photos
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('photos', 'photos', false, 10485760,
array['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/bmp']);
create policy "Upload into your own folder" on storage.objects
for insert to authenticated
with check (bucket_id = 'photos' and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "Your own photos, and approved ones" on storage.objects
for select to authenticated
using (
bucket_id = 'photos'
and (
owner_id = (select auth.uid())::text
or exists (select 1 from public.photos p where p.path = objects.name and p.status = 'approved')
)
);
- Private means every read goes through policies. In a private bucket "all operations are subject to access control via RLS policies" (Storage Buckets). The read policy joins the
photostable, so a photo becomes visible to other signed-in users the moment its row is approved. - The bucket accepts only what the API can check: JPEG, PNG, WebP, GIF or BMP, up to 10,485,760 bytes, the 10 MB limit in Image moderation.
- No overwrites, no deletes. Supabase says an overwrite needs "SELECT and UPDATE permissions" (Storage Access Control). With no update or delete policy, a user cannot swap an approved photo for another; the SQL test confirmed both.
From the app, with supabase-js:
// A post and a photo. Both start as pending.
await supabase.from("posts").insert({ body });
const path = `${user.id}/${crypto.randomUUID()}`;
const { error } = await supabase.storage.from("photos").upload(path, file, { contentType: file.type });
if (!error) await supabase.from("photos").insert({ path });
// Reading needs no status filter: row-level security returns approved rows plus your own.
const { data: posts } = await supabase.from("posts").select("id, body, status").order("created_at", { ascending: false });
const { data: signed } = await supabase.storage.from("photos").createSignedUrl(path, 3600);
Call the moderation API from an Edge Function
The first function hears about each new row from Postgres and queues its check. Set it up once:
- Create a secret key named
moderationunder Settings → API keys. Supabase lets a project add named secret keys next todefault(Securing Edge Functions), and a named key can be rotated without touching the others (@supabase/server). - Store the Detectivision API key, one with Write permission (Quickstart), as a function secret:
supabase secrets set DETECTIVISION_API_KEY=.... A secret's name cannot start withSUPABASE_. - Turn off the platform's JWT check for both functions. Neither is called with a user's token: Postgres sends an API key, and Detectivision signs its requests instead.
supabase functions deploythen applies these settings (Function Configuration).
[functions.moderate]
verify_jwt = false
[functions.moderation-result]
verify_jwt = false
import { withSupabase } from "npm:@supabase/server@^1";
const API_URL = "https://api.detectivision.ai/api/v1/moderation";
// Longer than an async check can wait: one that cannot run within about a day fails.
const SIGNED_URL_SECONDS = 25 * 60 * 60;
export default {
fetch: withSupabase({ auth: "secret:moderation" }, async (req, ctx) => {
const { table, record } = await req.json();
if (table !== "posts" && table !== "photos") return Response.json({ skipped: true });
const db = ctx.supabaseAdmin;
// The payload says which row; the database says what is in it.
const { data: row } = await db.from(table).select("*").eq("id", record.id).single();
if (!row || row.status !== "pending" || row.request_id) return Response.json({ skipped: true });
let request;
if (table === "posts") {
request = { type: "text_moderation", input: row.body };
} else {
const { data: signed, error } = await db.storage
.from("photos")
.createSignedUrl(row.path, SIGNED_URL_SECONDS);
if (error) {
await db.from(table).update({ status: "review", moderation: { error: error.message } }).eq("id", row.id);
return Response.json({ review: true });
}
request = { type: "image_moderation", input: signed.signedUrl };
}
const res = await fetch(API_URL, {
method: "POST",
headers: {
"Api-Key": Deno.env.get("DETECTIVISION_API_KEY")!,
"Content-Type": "application/json",
},
body: JSON.stringify({ ...request, mode: "detect", async: true, metadata: { table, id: row.id } }),
});
const answer = await res.json();
if (res.status === 202) {
await db.from(table).update({ request_id: answer.id }).eq("id", row.id);
} else if (res.status !== 429 && res.status < 500) {
// The input cannot be checked, for example an empty text: a person decides.
await db.from(table).update({ status: "review", moderation: { error: answer.error } }).eq("id", row.id);
}
// 429 and 5xx leave the row pending with no request_id; the sweeper sends it again.
return Response.json({ status: res.status });
}),
};
- Only the database gets in.
auth: "secret:moderation"accepts the secret key namedmoderationon theapikeyheader and nothing else. In the Deno test, calls with no key, with thedefaultsecret key and with the publishable key all got401. - The row is read again. The trigger only says which row is new, so a forged call could not get one text checked and another row approved.
- The check is queued.
async: truemakes the API answer202with the request'sidat once and run the check shortly after, at the table price (Async requests). The function stores thatidinrequest_id. - Busy answers wait for the sweeper. A
429or5xxleaves the row pending with norequest_id, and the sweeper below sends it again. Any other error is a4xx, which needs a changed request: an input that cannot be used, a wrong key, or no credits left. So the row goes to review with the error code. Errors cost nothing (Errors). ctx.supabaseAdminbypasses row-level security, as the service role always does; that is how the function writes statuses that clients cannot.
The function sends a request like this one. Example response (format from the API reference; values illustrative):
curl https://api.detectivision.ai/api/v1/moderation \
-H "Api-Key: $DETECTIVISION_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "image_moderation",
"input": "https://<project-ref>.supabase.co/storage/v1/object/sign/photos/<user-id>/<file>?token=...",
"mode": "detect",
"async": true,
"metadata": { "table": "photos", "id": 42 }
}'{
"id": 1043,
"type": "image_moderation",
"status": "queued",
"metadata": { "table": "photos", "id": 42 }
}Run it on every insert
Supabase's Database Webhooks are "a convenience wrapper around triggers using the pg_net extension" (Database Webhooks). This guide writes the trigger in SQL, so the secret key can live in Vault, which Supabase's scheduling guide recommends for the keys that pg_net calls send. Enable the pg_net and pg_cron extensions first.
-- Once, with your values: the project URL and the secret key named "moderation".
select vault.create_secret('https://<project-ref>.supabase.co', 'project_url');
select vault.create_secret('sb_secret_...', 'moderation_key');
create schema if not exists private;
create function private.send_for_moderation(tbl text, row_id bigint)
returns bigint
language sql
security definer
set search_path = ''
as $$
select net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
|| '/functions/v1/moderate',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'moderation_key')
),
body := jsonb_build_object('type', 'INSERT', 'table', tbl, 'record', jsonb_build_object('id', row_id)),
timeout_milliseconds := 10000
);
$$;
create function private.on_new_row()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
perform private.send_for_moderation(tg_table_name, new.id);
return new;
end;
$$;
create trigger send_for_moderation after insert on public.posts
for each row execute function private.on_new_row();
create trigger send_for_moderation after insert on public.photos
for each row execute function private.on_new_row();
- Nothing waits on the network. pg_net is asynchronous, and "HTTP requests are not started until the transaction is committed" (pg_net). An insert that rolls back never reaches the function, and a slow function never slows an insert.
- The payload uses a Database Webhook's field names,
type,tableandrecord, but only the row'sid: the function reads the rest itself. - The timeout is raised to 10 seconds. pg_net's default is 2,000 milliseconds. The function only reads a row and queues a check.
security definerwith an emptysearch_pathlets the trigger read Vault, which signed-in users cannot. The functions sit in aprivateschema that the API does not expose.
pg_net keeps its requests and responses in unlogged tables, which "are not preserved during a crash or unclean shutdown", and keeps responses for 6 hours. So a small job sends again any row that has waited 10 minutes without a request id:
select cron.schedule('moderation-sweeper', '*/10 * * * *', $$
select private.send_for_moderation(t.tbl, t.id)
from (
select 'posts' as tbl, id from public.posts
where status = 'pending' and request_id is null and created_at < now() - interval '10 minutes'
union all
select 'photos', id from public.photos
where status = 'pending' and request_id is null and created_at < now() - interval '10 minutes'
limit 100
) t
$$);
Images in Storage: how long the signed URL must live
A queued check downloads the image when it runs, not when you send it. Two documented facts set the expiry:
- Detectivision: async checks run "as soon as there is capacity, usually within seconds", but immediate requests go first, and a check that cannot run within about a day fails with
moderation.failed. "Keep URLs reachable until the result arrives" (Async requests). - Supabase: "Signed URLs remain valid until their expiry time", and to revoke one early you contact Supabase support (Serving assets from Storage). Deleting the file ends it too, once Supabase's CDN drops any copy it cached; with the Smart CDN of Pro plans and above, that takes up to 60 seconds (Smart CDN).
| Expiry | Covers | What it costs you |
|---|---|---|
| 25 hours, as in the code | The longest an async check can wait before it fails | The URL works for a day. It is sent only to the API, and a rejected file is deleted, which ends it |
| 1 hour | Ordinary queue times | Under heavy load the check can fail with moderation.failed and invalid_input (free). Sign again and resend |
The API downloads with the user agent DetectivisionBot/1.0 (+https://detectivision.ai) and must finish within 10 seconds (Image moderation). A Supabase signed URL needs no allow-list. If you check photos at once instead, without async, a URL of a few minutes is enough, and the check costs twice as much.
Results by webhook: a second Edge Function
Add the endpoint https://<project-ref>.supabase.co/functions/v1/moderation-result under Dashboard → Webhooks in Detectivision, subscribe it to moderation.completed and moderation.failed, and store its signing secret: supabase secrets set DETECTIVISION_WEBHOOK_SECRET=whsec_....
import { withSupabase } from "npm:@supabase/server@^1";
import { Webhook } from "npm:standardwebhooks@^1";
const webhook = new Webhook(Deno.env.get("DETECTIVISION_WEBHOOK_SECRET")!);
// Your own line. The API raises a flag at 0.5 or above; from here up, reject on its own.
const REJECT_AT = 0.7;
type Flag = { flag: string; confidence: number };
export default {
fetch: withSupabase({ auth: "none" }, async (req, ctx) => {
const body = await req.text();
let event;
try {
event = webhook.verify(body, Object.fromEntries(req.headers)) as {
type: string;
data: {
id: number;
status?: "completed" | "partial";
flagged?: boolean;
flags?: Flag[];
error?: string;
metadata?: { table?: string; id?: number };
};
};
} catch {
return new Response("bad signature", { status: 401 });
}
const { type, data } = event;
const table = data.metadata?.table;
const decides = type === "moderation.completed" || type === "moderation.failed";
if (!decides || (table !== "posts" && table !== "photos")) return new Response(null, { status: 204 });
let status;
if (type === "moderation.failed" || data.status === "partial") status = "review";
else if (!data.flagged) status = "approved";
else status = data.flags!.some((f) => f.confidence >= REJECT_AT) ? "rejected" : "review";
// Only a row that is still pending changes, so a repeated delivery does nothing.
const { data: changed, error } = await ctx.supabaseAdmin
.from(table)
.update({ status, moderation: { flags: data.flags ?? [], error: data.error ?? null, requestId: data.id } })
.eq("id", data.metadata!.id)
.eq("status", "pending")
.select();
if (error) return new Response("try again", { status: 500 });
if (table === "photos" && status === "rejected" && changed.length > 0) {
// Remove the file; its signed URLs stop working with it.
await ctx.supabaseAdmin.storage.from("photos").remove([changed[0].path]);
}
return new Response(null, { status: 204 });
}),
};
Example webhook body (format from the webhooks reference; values illustrative):
{
"type": "moderation.completed",
"timestamp": "2026-10-16T09:41:07.512Z",
"data": {
"id": 1043,
"type": "image_moderation",
"mode": "detect",
"status": "completed",
"flagged": true,
"flags": [{ "flag": "sexual", "confidence": 0.93 }],
"checked": ["hate", "scam", "sexual", "violence"],
"unchecked": [],
"tookMs": 412,
"usage": { "charged": 1, "remaining": 4210 },
"metadata": { "table": "photos", "id": 42 },
"source": "api"
}
}
- The signature is the authentication. Supabase's guide for external webhooks is
auth: "none"withverify_jwt = false, then verifying the provider's signature in the handler. Detectivision signs the Standard Webhooks way (Webhooks), so thestandardwebhookslibrary checks the raw body, the timestamp and thewhsec_secret in one call. Supabase's own Send Email Hook example uses the same library in an Edge Function. - One conditional update makes it idempotent.
update ... where status = 'pending'changes a row once. A repeated delivery, or themoderation.flaggedevent if you subscribed to it, finds nothing left to change. Failed deliveries come again for about 28 hours. - Your line, above the API's. The API raises a flag at 0.5 or above, and findings below that never come back (Flags). Here 0.7 or more rejects and anything lower goes to a person. A
partialresult and a failed check also go to review (Responses). - Errors ask for a retry. If the update fails, the function answers
500, and Detectivision sends the event again. Answer within 10 seconds either way. - A rejected photo's file is removed, which also ends its signed URLs.
What it costs
- Detectivision: the table price, because every check here is queued. Text costs a unit per started 2,500 characters, and an image in
detectmode one unit; a unit is a credit async and twice that at once (Credits and billing). Errors,partialresults and failed async checks cost nothing. - Plans: Basic comes with 10,000 text credits and 10,000 image credits; Pricing has the trial and the other plans.
- Supabase: each post or photo takes two Edge Function invocations, one per function, plus one more each time the sweeper resends it. Supabase bills invocations beyond each plan's quota (Edge Functions pricing).
Limits, and what this does not do
- No official integration. Supabase does not ship this; it is SQL and two functions in your own project.
- Whole items only. A flag covers the whole post or photo, with no word positions or blurred copy, so a row is either shown or held back.
- Ten languages. Text in English, Spanish, Portuguese, French, German, Italian, Turkish, Arabic, Russian and Hindi gets reliable results; other languages are checked, but the results are not reliable (Text moderation).
- Text and photos only. Video, audio and live stream moderation are coming soon.
- No promised timing. Queued checks usually run within seconds and can wait longer under load, so show a pending post as pending.
- No review screen. Rows marked
reviewwait for a person: query them in your admin tools, or work from the dashboard's Logs, where a moderator opens a check with yourmetadataand the actions you take reach your system asmoderation.actionwebhooks (Metadata and actions). - No edits. Clients have no update grant. To allow edits, insert the new version as a new pending row.
- Platform limits. Edge Functions get 2 seconds of CPU per request, not counting async I/O, and 150 seconds of wall time on the Free plan or 400 on paid plans (Limits); pg_net is meant for at most 200 requests a second.
- Legacy keys. Supabase says it is "deprecating the anon and service_role keys by the end of 2026" (API keys). Nothing here uses them.
How this was checked
All of this ran on 16 October 2026, and the Supabase documentation quoted above was read that day; the links are under Sources.
| What | How | Result |
|---|---|---|
| SQL | PostgreSQL 18.6 (PGlite 0.5.10), with stand-ins for auth.uid(), storage.objects, storage.foldername(), Vault, net.http_post and cron.schedule | 28 checks pass: grants, policies, the trigger's request, the sweeper |
| Edge Function code | deno check in Deno 2.9.6, with @supabase/server 1.9.2, supabase-js 2.117.3 and standardwebhooks 1.1.1 | No errors |
| Function behaviour | Both functions in Deno, against a local stand-in for Supabase's REST and Storage APIs and a stub of the Detectivision API that answers in the documented format | 19 checks pass: key checks, queuing, the signed URL's lifetime, 429 and 400 answers, the signature and its 5-minute window, verdicts, repeated and flagged deliveries, file removal |
| App snippet | supabase-js 2.117.3 | No type errors |
Not covered: a hosted Supabase project (pg_net, Vault and pg_cron for real, the Edge Runtime, and the injection of named secret keys) and real API answers. The answers shown above are examples in the documented format.
Next: read the docs, or see the Firebase version with Cloud Functions and security rules in Firebase content moderation.
Frequently asked
Why does each flagged check reach my webhook twice?
A flagged check sends two events, moderation.completed and moderation.flagged. Subscribe the endpoint to moderation.completed and moderation.failed only. The update in moderation-result also changes a row only while it is pending, so a repeated or doubled event does nothing.
What happens if the signed URL expires before the check runs?
The check fails when it runs, because the API cannot download the image: the webhook gets moderation.failed with invalid_input, and nothing is charged. The handler sends the photo to review. A URL that lives a little over a day avoids this, because an async check that cannot run within about a day fails anyway.
Do I still need the service_role key?
No. Supabase lists the anon and service_role keys as legacy and says it is deprecating them by the end of 2026. Here a named secret key authenticates the database's calls, and ctx.supabaseAdmin comes from the project's secret keys, which the platform provides to Edge Functions. The SQL still grants rights to the service_role Postgres role, because secret keys act through that role.
Can a user publish a post by setting its status from the client?
No. The grants let signed-in users insert only the body of a post and the path of a photo, and update nothing, so every row starts as pending. In the SQL test, inserting a status and updating one both failed with permission denied.
Can Postgres call the moderation API directly with pg_net?
For text it could: pg_net can POST an async request with the API key read from Vault. But the API's answer, a request id or an error, would only sit in net._http_response for 6 hours, and nothing would act on it. Photos also need a signed URL from the Storage API, which is simpler to get in an Edge Function.
Moderate Firebase messages and uploads: a Cloud Function sends each new text or image to a moderation API and keeps it hidden until it is approved. With code.
A reference design for moderating user content: where to check, when to block or hold, how to route doubtful cases to people, and how decisions reach your app.
Send content that can wait, get its id at once, and pay half: results come by webhook.
Get events at your own URL as they happen. Finished checks, flagged checks, failed async checks and actions, signed with your endpoint's secret.
Check comments and chat messages for insults, hate, harassment and self-harm in ten languages, with a flag for each kind of toxicity.
- Securing Edge Functions
- Authorization headers
- API keys
- Environment variables (Edge Functions secrets)
- Function Configuration
- Edge Functions limits
- Edge Functions pricing
- Database Webhooks
- pg_net: Async Networking
- Vault
- Scheduling Edge Functions
- Row Level Security
- Storage Access Control
- Storage Helper Functions
- Storage Buckets
- Serving assets from Storage (signed URLs)
- Smart CDN
- JavaScript: createSignedUrl
- Custom Auth Emails with React Email and Resend (standardwebhooks in an Edge Function)
- @supabase/server (README, version 1.9.2)
- Detectivision API status (GET /api/v1/status)
- Async requests
- Webhooks
- Image moderation
- Text moderation
- Flags
- Responses
- Credits and billing
- Errors
- Metadata and actions
- Quickstart