eng-supabase-edge-functions-patterns
Rating is derived from the repo's GitHub stars and shown for reference.
shell_executionnetwork_accesscredential_accessautomation_control
name: eng-supabase-edge-functions-patterns
description: Use when writing, deploying, or debugging Supabase Edge Functions (Deno runtime) for the legal AI backend. Covers authentication patterns, CORS configuration, structured error handling, JWT validation, calling external APIs (Anthropic, PostHog), and patterns specific to multi-tenant legal AI workloads.
license: MIT
metadata:
id: eng.supabase-edge-functions-patterns
category: eng
jurisdictions: [multi]
priority: P2
intent: [eng, supabase, edge-functions, deno, serverless]
related: [eng-tenant-isolation-row-level-security, eng-supabase-index-knowledge-pipeline, eng-streaming-response-rules-mobile, eng-pii-redaction-preprocessor]
source: Louis — HAQQ Legal AI (github.com/sboghossian/mini-claude-for-legal)
version: "1.0"
Supabase Edge Functions Patterns
What it does
Supabase Edge Functions are Deno-based serverless functions deployed to the global Supabase edge network. In this legal AI product they handle: LLM proxying (streaming chat), document ingestion, PII redaction, billing webhooks, and eval runs. This skill documents the canonical patterns all Edge Functions in the project must follow.
Setup / auth
supabase functions new my-function
supabase functions deploy my-function --no-verify-jwt # only for public webhooks
supabase secrets set ANTHROPIC_API_KEY=sk-ant-...
Every function that handles user requests must verify the Supabase JWT:
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: req.headers.get("Authorization")! } } }
);
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return new Response("Unauthorized", { status: 401 });
Do not use the service role key for user-facing functions. It bypasses RLS.
Capabilities
Standard function skeleton
import { corsHeaders } from "../_shared/cors.ts";
Deno.serve(async (req: Request) => {
// CORS pre-flight
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
// 1. Auth
const user = await verifyUser(req);
// 2. Parse & validate input
const body = await req.json();
const parsed = InputSchema.safeParse(body);
if (!parsed.success) {
return errorResponse(400, "Invalid input", parsed.error);
}
// 3. Business logic
const result = await doWork(user, parsed.data);
// 4. Return
return new Response(JSON.stringify(result), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err) {
return errorResponse(500, "Internal error", err);
}
});
CORS headers (_shared/cors.ts)
export const corsHeaders = {
"Access-Control-Allow-Origin": Deno.env.get("ALLOWED_ORIGIN") ?? "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
In production, set ALLOWED_ORIGIN to the app's domain. Wildcard * is acceptable for staging only.
Streaming response pattern (LLM proxy)
Deno.serve(async (req: Request) => {
const user = await verifyUser(req);
const { message } = await req.json();
const anthropicStream = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": Deno.env.get("ANTHROPIC_API_KEY")!,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: tokenBudget(user.tier),
stream: true,
messages: [{ role: "user", content: message }],
}),
});
return new Response(anthropicStream.body, {
headers: {
...corsHeaders,
"Content-Type": "text/event-stream",
"Cache-Control": "no-store",
},
});
});
Error response helper
function errorResponse(status: number, message: string, detail?: unknown): Response {
console.error({ status, message, detail });
return new Response(
JSON.stringify({ error: message, detail: detail instanceof Error ? detail.message : detail }),
{ status, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
Environment variable patterns
| Variable | Scope | Notes |
|---|---|---|
SUPABASE_URL |
Auto-injected | Do not set manually |
SUPABASE_ANON_KEY |
Auto-injected | Use for user-context clients |
SUPABASE_SERVICE_ROLE_KEY |
Secret | Use only for admin functions (billing webhooks) |
ANTHROPIC_API_KEY |
Secret | Never log |
POSTHOG_SERVER_KEY |
Secret | Never log |
ALLOWED_ORIGIN |
Secret | Per-env |
Usage patterns
Pattern — Calling Anthropic with per-user BYO key
const apiKey = user.byoKey
? await decryptBYOKey(user.byoKeyEncrypted)
: Deno.env.get("ANTHROPIC_API_KEY")!;
Never store a decrypted BYO key in any log or variable that persists beyond the request lifecycle.
Pattern — Database insert with RLS context
// Using anon key + JWT = RLS enforced automatically
const { error } = await supabase
.from("chat_messages")
.insert({ user_id: user.id, content: redactedMessage, created_at: new Date() });
Pattern — Invoking another Edge Function
const res = await supabase.functions.invoke("pii-redactor", {
body: { text: rawMessage },
});
Permissions & safety
- Use
SUPABASE_ANON_KEY(+ JWT) for all user-facing functions. The JWT propagates to RLS. - Use
SUPABASE_SERVICE_ROLE_KEYonly for admin/system functions (e.g., billing webhooks from Stripe that don't have a user JWT). Mark these functions--no-verify-jwtin deployment. - Every function must log its
user.idandtenantIdat the start of the request for audit purposes. Never logAuthorizationheader or API keys. - Set a hard timeout: Supabase Edge Functions have a 150-second wall-clock limit. For long operations (render, batch embed), use a Postgres job queue (pg_cron + Supabase Queues) instead of synchronous function calls.
Failure modes
| Failure | Impact | Mitigation |
|---|---|---|
| Missing CORS pre-flight handler | Browser blocks requests | Always handle OPTIONS first |
| Service role key in user function | RLS bypassed; tenant leak | Use anon key by default; require code review for service role |
| Unhandled promise rejection | 500 with no context | Wrap all async work in try/catch; use errorResponse helper |
| Cold start latency > 2 s | Poor mobile UX | Keep _shared/ imports minimal; avoid heavy node_modules |
| JWT not re-validated on reconnect | Stale session serves other user | Re-validate on every reconnect for streaming endpoints |
Related skills
- [[eng-tenant-isolation-row-level-security]] — RLS policies that Edge Functions must respect
- [[eng-supabase-index-knowledge-pipeline]] — document ingestion Edge Function
- [[eng-streaming-response-rules-mobile]] — mobile streaming endpoint pattern
- [[eng-pii-redaction-preprocessor]] — called as an inner Edge Function from the chat handler