Guide · Published · Updated

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

StepWhereWhat happens
1Your appInserts 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.
2PostgresAn after insert trigger calls net.http_post. pg_net sends the request once the transaction commits.
3Edge Function moderateChecks 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.
4DetectivisionRuns the check, then posts moderation.completed or moderation.failed to your endpoint.
5Edge Function moderation-resultVerifies the signature, sets approved, review or rejected on rows still pending, and removes rejected files.
6Your appReads 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.

tables and policies
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 body or path; status keeps 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 got permission denied.
  • Policies decide what a client may read. Supabase describes a policy as "adding a WHERE clause to every query". So select * from posts returns approved posts plus the reader's own, with no error and no filter in your code.
  • The (select auth.uid()) form and to authenticated follow Supabase's RLS guide: the policy is not evaluated for signed-out visitors at all.
  • The 2,000-character check on body is a choice for this example. The API accepts texts of up to 10,000 characters (Text moderation).

A private bucket for photos

storage bucket and policies
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 photos table, 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:

app.ts
// 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:

  1. Create a secret key named moderation under Settings → API keys. Supabase lets a project add named secret keys next to default (Securing Edge Functions), and a named key can be rotated without touching the others (@supabase/server).
  2. 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 with SUPABASE_.
  3. 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 deploy then applies these settings (Function Configuration).
supabase/config.toml
[functions.moderate]
verify_jwt = false

[functions.moderation-result]
verify_jwt = false
supabase/functions/moderate/index.ts
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 named moderation on the apikey header and nothing else. In the Deno test, calls with no key, with the default secret key and with the publishable key all got 401.
  • 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: true makes the API answer 202 with the request's id at once and run the check shortly after, at the table price (Async requests). The function stores that id in request_id.
  • Busy answers wait for the sweeper. A 429 or 5xx leaves the row pending with no request_id, and the sweeper below sends it again. Any other error is a 4xx, 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.supabaseAdmin bypasses 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):

Request
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 }
  }'
Response
{
  "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.

trigger
-- 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, table and record, but only the row's id: 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 definer with an empty search_path lets the trigger read Vault, which signed-in users cannot. The functions sit in a private schema 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:

sweeper
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).
ExpiryCoversWhat it costs you
25 hours, as in the codeThe longest an async check can wait before it failsThe URL works for a day. It is sent only to the API, and a rejected file is deleted, which ends it
1 hourOrdinary queue timesUnder 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_....

supabase/functions/moderation-result/index.ts
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):

moderation.completed
{
  "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" with verify_jwt = false, then verifying the provider's signature in the handler. Detectivision signs the Standard Webhooks way (Webhooks), so the standardwebhooks library checks the raw body, the timestamp and the whsec_ 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 the moderation.flagged event 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 partial result 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 detect mode one unit; a unit is a credit async and twice that at once (Credits and billing). Errors, partial results 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 review wait for a person: query them in your admin tools, or work from the dashboard's Logs, where a moderator opens a check with your metadata and the actions you take reach your system as moderation.action webhooks (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.

WhatHowResult
SQLPostgreSQL 18.6 (PGlite 0.5.10), with stand-ins for auth.uid(), storage.objects, storage.foldername(), Vault, net.http_post and cron.schedule28 checks pass: grants, policies, the trigger's request, the sweeper
Edge Function codedeno check in Deno 2.9.6, with @supabase/server 1.9.2, supabase-js 2.117.3 and standardwebhooks 1.1.1No errors
Function behaviourBoth 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 format19 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 snippetsupabase-js 2.117.3No 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.

Start the free trial

Next: read the docs, or see the Firebase version with Cloud Functions and security rules in Firebase content moderation.

Questions

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.

Related articles
Sources
  1. Securing Edge Functions, Supabase, read on 16 October 2026
  2. Authorization headers, Supabase, read on 16 October 2026
  3. API keys, Supabase, read on 16 October 2026
  4. Environment variables (Edge Functions secrets), Supabase, read on 16 October 2026
  5. Function Configuration, Supabase, read on 16 October 2026
  6. Edge Functions limits, Supabase, read on 16 October 2026
  7. Edge Functions pricing, Supabase, read on 16 October 2026
  8. Database Webhooks, Supabase, read on 16 October 2026
  9. pg_net: Async Networking, Supabase, read on 16 October 2026
  10. Vault, Supabase, read on 16 October 2026
  11. Scheduling Edge Functions, Supabase, read on 16 October 2026
  12. Row Level Security, Supabase, read on 16 October 2026
  13. Storage Access Control, Supabase, read on 16 October 2026
  14. Storage Helper Functions, Supabase, read on 16 October 2026
  15. Storage Buckets, Supabase, read on 16 October 2026
  16. Serving assets from Storage (signed URLs), Supabase, read on 16 October 2026
  17. Smart CDN, Supabase, read on 16 October 2026
  18. JavaScript: createSignedUrl, Supabase, read on 16 October 2026
  19. Custom Auth Emails with React Email and Resend (standardwebhooks in an Edge Function), Supabase, read on 16 October 2026
  20. @supabase/server (README, version 1.9.2), Supabase on GitHub, read on 16 October 2026
  21. Detectivision API status (GET /api/v1/status), Detectivision, read on 16 October 2026
  22. Async requests, Detectivision docs, read on 16 October 2026
  23. Webhooks, Detectivision docs, read on 16 October 2026
  24. Image moderation, Detectivision docs, read on 16 October 2026
  25. Text moderation, Detectivision docs, read on 16 October 2026
  26. Flags, Detectivision docs, read on 16 October 2026
  27. Responses, Detectivision docs, read on 16 October 2026
  28. Credits and billing, Detectivision docs, read on 16 October 2026
  29. Errors, Detectivision docs, read on 16 October 2026
  30. Metadata and actions, Detectivision docs, read on 16 October 2026
  31. Quickstart, Detectivision docs, read on 16 October 2026