Securing an AI agent in Supabase Edge Functions
How-to
A lot of AI features ship as a Supabase Edge Function: the browser calls
/functions/v1/agent, the function calls a model with some tools, and the
tools read and write the project's Postgres. Many of those functions were
written by a coding agent from a prompt, which is part of why they look the
way they do.
Secrets in an Edge Function come from supabase secrets set, one per key, read
with Deno.env.get. Locally they come from supabase/functions/.env. And
four are injected into every function without anyone setting them:
SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, and
SUPABASE_DB_URL. The third one bypasses row level security. An agent in an
Edge Function therefore holds, by default, a key that reads and writes every
table in the project, and it holds it in the same environment as the OpenAI
key and whatever else the tools need.
What to fix, in order
- The agent acts on the database as the service role.
- Every provider and tool key is a separate
supabase secrets set, in every environment, and in a.envfile locally. - Tool keys are real values a tool can be persuaded to misuse or repeat.
1. Act as the user, not the service role
The service role key is in the environment whether you want it or not, so the fix is to not use it. The request that invoked the function carries the user's JWT. Build the Supabase client with that, and every query the agent's tools run is subject to the same row level security as the user's own browser:
import { createClient } from "npm:@supabase/supabase-js";
Deno.serve(async (req) => {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: req.headers.get("Authorization")! } } },
);
// tools use `supabase` and can only see this user's rows
});
This does not remove SUPABASE_SERVICE_ROLE_KEY from Deno.env. Nothing can.
It removes it from the code path, so a tool the model was talked into running
operates with the user's permissions. If a function needs the service
role, give that function no model in it, and call it from the agent's function
over HTTP with a narrow contract.
2. One secret instead of a list
Put the provider and tool keys in a seekrit environment and set exactly one secret on the project:
seekrit secrets import supabase/functions/.env --app app --env production
seekrit token create --app app --env production --name edge-functions
supabase secrets set SEEKRIT_TOKEN=skt_…
rm supabase/functions/.env
The Edge Functions runtime is Deno, and the SDK is WebCrypto and fetch with
no Node built-ins, so it imports with an npm: specifier and runs as is:
import { Seekrit } from "npm:@seekrit/sdk";
const seekrit = new Seekrit({ token: Deno.env.get("SEEKRIT_TOKEN") });
Deno.serve(async (req) => {
const secrets = await seekrit.resolve();
// secrets.OPENAI_API_KEY, secrets.TAVILY_API_KEY, ...
});
The client is constructed at module scope, so an isolate that serves several
requests resolves once and reuses the result. supabase secrets list now
shows the four defaults plus one. Adding a key to the agent is a change in
seekrit, not a supabase secrets set per environment plus a .env edit.
Revoking the token cuts the function off from every key at once.
Locally, supabase/functions/.env holds one line, SEEKRIT_TOKEN=…, or
nothing if you run seekrit run -- supabase functions serve and let the token
arrive from the CLI's session.
3. Placeholders for the keys tools spend
A tool that calls Stripe does not need the Stripe key. It needs its request to
arrive at Stripe with the key attached. @seekrit/sdk/fetch attaches it and
the function holds a placeholder:
import { createOpenAI } from "npm:@ai-sdk/openai";
import { generateText, tool } from "npm:ai";
import { seekritFetch } from "npm:@seekrit/sdk/fetch";
import { z } from "npm:zod";
const fetch = seekritFetch({
token: Deno.env.get("SEEKRIT_TOKEN"),
rules: [
{ host: "api.openai.com", methods: ["POST"], paths: ["/v1/**"], allow: ["OPENAI_API_KEY"] },
{ host: "api.stripe.com", methods: ["GET"], paths: ["/v1/charges/*"], allow: ["STRIPE_KEY"] },
],
});
const openai = createOpenAI({ apiKey: "{{seekrit:OPENAI_API_KEY}}", fetch });
Deno.serve(async (req) => {
const { question } = await req.json();
const { text } = await generateText({
model: openai("gpt-5.6-terra"),
prompt: question,
tools: {
lookupCharge: tool({
description: "Look up a Stripe charge",
inputSchema: z.object({ id: z.string() }),
execute: async ({ id }) => {
const res = await fetch(`https://api.stripe.com/v1/charges/${id}`, {
headers: { authorization: "Bearer {{seekrit:STRIPE_KEY}}" },
});
const body = await res.json();
return { amount: body.amount, status: body.status };
},
}),
},
});
return Response.json({ text });
});
The function's source, its logs in the Supabase dashboard, and the model's
context all contain {{seekrit:STRIPE_KEY}}. The value exists inside one
outbound request. The rule allows GET on charges and nothing else, so a
refund is refused inside the function with a 403 naming the rule, before the
request leaves. The substitution runs in the function's process, which makes
it a weaker boundary than a separate proxy and a much stronger one than a value
in Deno.env. The in-process guide has
the trade-off.
Logs
Edge Function logs are retained and searchable in the dashboard, and
console.log(error) on a failed provider call is how a key ends up in them,
because provider SDKs include the request in some error objects. With
placeholders the logged request carries {{seekrit:OPENAI_API_KEY}}. Without
them, log the fields you need and not the error object.
What this looks like afterwards
| Before | After | |
|---|---|---|
| Database access from tools | service role, every table | the user's JWT, their rows |
supabase secrets list | one entry per key | the four defaults plus SEEKRIT_TOKEN |
Local .env | every key | one line, or none |
| Tool keys in the function | values | placeholders, allowlisted by host, method, and path |
| Rotating a key | supabase secrets set per environment, redeploy | change it in seekrit |
The React guide covers the same placeholder shape for a Next.js frontend that talks to Supabase, and the AI SDK guide has the provider setup in detail. Setup is three commands, and the values are encrypted before they are stored, so the service holding them cannot read them.