Functions and production

Add a trusted Edge Function

Use a server-side function for secrets and privileged integrations while still validating identity, authorization, input, and external responses.

An Edge Function is server-side code that Supabase runs for you, on Deno, close to your users. Its job in an app is to hold what the browser must never see: provider secrets and trusted integration code. But running on the server does not make a function safe. A function that trusts its input is just a new attack surface with lower latency.

The checklist is the same for every function: verify the caller, authorize the requested resource, validate the input, set timeouts, and decide what a retry means. Skip one and the function undoes protection the rest of the platform gives you.

Let’s build one that does one narrow thing, emailing a note to the person who owns it:

supabase functions new send-note
supabase functions serve send-note

The first command creates supabase/functions/send-note/index.ts. The second runs it locally. The function establishes who is calling before anything else, using the request’s own authorization header:

import { createClient } from 'npm:@supabase/supabase-js@2'

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')! } } },
  )

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return new Response('Unauthorized', { status: 401 })

  const { noteId } = await req.json()
  const { data: note } = await supabase
    .from('notes').select().eq('id', noteId).single()
  if (!note) return new Response('Not found', { status: 404 })

  // call the email provider here, with a timeout
  return new Response('Sent', { status: 200 })
})

Notice that the client is created with the publishable key plus the caller’s token, not with the secret key. That means RLS still applies inside the function. Asking for another user’s noteId returns nothing, and the function answers 404 without any extra code. This is the pattern I prefer: keep a user-scoped client for reads and writes, and let the database keep enforcing the boundary.

When you really need privilege

Sometimes a step needs to bypass RLS, for example writing to an audit table the user can’t touch. A secret-key client inside the function does that. Use it only after the function has established the same boundary itself: verify the caller, authorize the requested resource, validate the input. Then run the one privileged statement and nothing broader.

Secrets live in function configuration, never in code:

supabase secrets set RESEND_API_KEY=re_8fKm2Vw...

Read it with Deno.env.get('RESEND_API_KEY'). For the outbound call, set a timeout with AbortSignal.timeout(5000) on fetch, check the provider’s response status, and decide what a retry means before you add one. An email sent twice is a bug you cannot unsend.

Test the denied paths first

A request with no session must get 401:

curl -i http://127.0.0.1:54321/functions/v1/send-note \
  -d '{"noteId": 1}'
# HTTP/1.1 401 Unauthorized

A valid session sending another user’s note ID must get 404. Only when both hold does the success case mean anything. Test the denials first, then the happy path.

Lesson completed