seekrit
← all posts

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

  1. The agent acts on the database as the service role.
  2. Every provider and tool key is a separate supabase secrets set, in every environment, and in a .env file locally.
  3. 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

BeforeAfter
Database access from toolsservice role, every tablethe user's JWT, their rows
supabase secrets listone entry per keythe four defaults plus SEEKRIT_TOKEN
Local .envevery keyone line, or none
Tool keys in the functionvaluesplaceholders, allowlisted by host, method, and path
Rotating a keysupabase secrets set per environment, redeploychange 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.