name: supabase-edge-functions-safe-rest
description: Safe Supabase Edge Functions REST API access pattern — avoids schema cache staleness, handles INSERT errors, and configures external CRM connections.
Supabase Edge Functions — Safe REST API Access Pattern
The Problem
Supabase Edge Functions (Deno) sometimes need to call the REST API internally (PostgREST at /rest/v1/). Using native fetch with the service role key sometimes fails with:
- -
PGRST204column-level INSERT errors (PostgREST schema cache is stale) - - Or the INSERT appears to succeed but rows aren't actually inserted
- - Allowlist/ invitation systems (email check on login)
- - Any table with RLS that anon key cannot read
- - Client-side auth checks that need server-side verification
The Problem (Updated — April 2026)
Supabase Edge Functions (Deno) have TWO recurring failure modes:
1. BOOT_ERROR from external imports via esm.sh (@supabase/supabase-js, node-fetch, etc.)
2. PGRST204 column-level INSERT errors (PostgREST schema cache is stale)
The Working Pattern — Native Fetch (AVOID external imports)
ALWAYS use native fetch in Edge Functions. External imports from esm.sh cause intermittent BOOT_ERROR in production. The Supabase REST API is fully accessible via plain HTTP.
`typescript
Deno.serve(async (req) => {
const url = Deno.env.get("SUPABASE_URL")!;
const key = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
// Query — use apikey ONLY from Edge Functions (Bearer causes JWSInvalidSignature)
const res = await fetch(${url}/rest/v1/tasks?select=id,title&limit=5, {
headers: { apikey: key }
});
// Insert — apikey ONLY from Edge Functions
const insertRes = await fetch(${url}/rest/v1/tasks, {
method: "POST",
headers: {
apikey: key,
"Content-Type": "application/json",
Prefer: "return=representation"
},
body: JSON.stringify([{ title: "New task", status: "todo" }])
});
// Update (requires row id)
const updateRes = await fetch(${url}/rest/v1/tasks?id=eq.${rowId}, {
method: "PATCH",
headers: {
apikey: key,
"Content-Type": "application/json",
Prefer: "return=minimal"
},
body: JSON.stringify({ status: "completed" })
});
});
`
When calling PostgREST from Edge Functions (dbFetch pattern)
⚠️ CRITICAL BUG DISCOVERED (June 2026): Never use Authorization: Bearer ${SERVICE_ROLE_KEY} when calling PostgREST from an Edge Function's dbFetch. PostgREST interprets Authorization: Bearer as a user JWT and validates it against GoTrue's JWT_SECRET (HMAC-SHA256). The service role key is an opaque JWT signed by a different key — PostgREST rejects it with JWSInvalidSignature → 403 Forbidden on every request.
The correct pattern for Edge Function→PostgREST calls uses only the apikey header:
`typescript
async function dbFetch(endpoint: string, options: RequestInit = {}) {
const url = ${SUPABASE_URL}/rest/v1${endpoint};
const headers: Record
"apikey": SERVICE_ROLE_KEY, // ← only this works from Edge Functions
// "Authorization": Bearer ${SERVICE_ROLE_KEY} ← NEVER in dbFetch — causes 403
"Content-Type": "application/json",
...(options.headers as Record
};
const res = await fetch(url, { ...options, headers });
return res;
}
`
The apikey header bypasses JWT validation entirely (PostgREST treats it as service role), while Authorization: Bearer forces JWT validation against JWT_SECRET.
Where each pattern applies:
| Context | Correct Headers | Why |
| --------- | ---------------- | ----- |
Edge Function dbFetch → PostgREST | apikey: SERVICE_ROLE_KEY ONLY | apikey bypasses JWT validation; Bearer triggers JWS validation |
| Browser → PostgREST (anon) | apikey: ANON_KEY + Authorization: Bearer ANON_KEY | Anon key is a valid user JWT — both headers work from browser |
| Browser → Edge Function | apikey: ANON_KEY + Authorization: Bearer USER_JWT | Edge function verifies JWT_SECRET separately; Browser uses user's access token |
| Edge Function → external service | Authorization: Bearer SERVICE_ROLE_KEY | External services validate service role JWT differently (not GoTrue) |
| Token | Where it works | Purpose |
| ------- | --------------- | --------- |
sbp_... (Management Access Token) | https://api.supabase.com/v1/... ONLY | Project management (create projects, manage API keys) |
eyJ... (Service Role Key) | https://[ref].supabase.co/rest/v1/* | Full database access (bypasses RLS) — only in Edge Functions |
eyJ... (Anon Key) | https://[ref].supabase.co/rest/v1/* | Public, respects RLS — safe for browser |
sbp_... does NOT work at project REST API endpoints — it returns Expected 3 parts in JWT; got 1.
Cloudflare can block api.supabase.com (returns HTTP 403) — making management API unusable from some networks.
Updating data from external code (outside Supabase)
Since sbp_... doesn't work at project REST API and you likely don't have SUPABASE_SERVICE_ROLE_KEY locally:
Solution: Create a thin Edge Function that performs the update internally:
`typescript
// File: supabase/functions/update-record/index.ts
// Deploy with: supabase functions deploy update-record --no-verify-jwt
Deno.serve(async (req) => {
const { table, id, updates } = await req.json();
const url = Deno.env.get("SUPABASE_URL")!;
const key = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const res = await fetch(${url}/rest/v1/${table}?id=eq.${id}, {
method: "PATCH",
headers: {
apikey: key, // apikey ONLY — Bearer causes JWSInvalidSignature in Edge→PostgREST calls
"Content-Type": "application/json",
Prefer: "return=minimal"
},
body: JSON.stringify(updates)
});
return new Response(JSON.stringify({ ok: res.ok, status: res.status }));
});
`
Note: Even when an Edge Function calls its own project's PostgREST (same process), use apikey ONLY. Bearer triggers GoTrue JWT validation which fails for service role keys.
Example usage — update a user's user_id field after UUID migration:
`bash
curl -X POST https://[ref].supabase.co/functions/v1/update-record \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"table":"profiles","id":"[profile-uuid]","updates":{"user_id":"[new-uuid]"}}'
`
Schema cache staleness workaround
If INSERT fails with PGRST204 (unknown column), PostgREST schema cache on Cloud is stale. Options:
1. Minimal columns only: Find the smallest working subset — avoid columns added after initial migration.
2. Retry with delay: Wait 30+ seconds and retry — cache auto-refreshes.
3. Use PATCH with known columns: Update only columns confirmed to exist.
Known-safe columns for tasks table
Based on ComercialRS investigation:
`typescript
{
external_ref: ref || null,
title: string,
status: 'todo' | 'in_progress' | 'completed',
channel: string,
due_date: string || null,
responsible_id: string || null,
// AVOID: source, responsible_email, link, start_date, priority, progress, created_by — NOT in table
}
`
Bypassing Client-Side RLS via Edge Function (Auth Allowlist Pattern)
When the browser needs to query a table protected by RLS that the anon key cannot access (returns 401), a common pattern is:
1. Create an Edge Function that uses SUPABASE_SERVICE_ROLE_KEY (bypasses RLS)
2. Call it from the browser with the anon key — it's a trusted internal function
3. Return only boolean/safe data — never leak sensitive data
`typescript
// supabase/functions/check-allowlist/index.ts
// Deploy: supabase functions deploy check-allowlist --no-verify-jwt
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
export const corsHeaders = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Origin': '*',
};
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const { email } = await req.json();
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // bypasses RLS
);
const { data } = await supabase
.from('allowed_emails')
.select('id')
.eq('email', email.toLowerCase().trim())
.maybeSingle();
return new Response(JSON.stringify({ allowed: !!data }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
});
`
When to use this pattern:
Important: The Edge Function must return only minimal safe data (e.g., allowed: true/false), never raw database rows that might contain sensitive info.
Auth admin operations (password reset, etc.)
`typescript
const supabase = createClient(url, key);
// Works for: updateUser, admin.listUsers, etc.
`
Note: The service role JWT (SUPABASE_SERVICE_ROLE_KEY env var) grants access to auth/v1/admin/* endpoints. The management API token (sbp_...) is for api.supabase.com — different endpoint, different purpose, cannot be used for row-level data access.
CRM external connections
When an Edge Function needs to call an external Supabase project:
Required secrets in Edge Function config:
`
EXTERNAL_SUPABASE_URL=https://[project-ref].supabase.co
EXTERNAL_SUPABASE_ANON_KEY=eyJ...
EXTERNAL_SUPABASE_SERVICE_ROLE_KEY=eyJ...
`
Without these, CRM sync functions return "Credenciais do banco externo não configuradas."
Deploy command
`bash
cd /var/www/comercialrs
SUPABASE_ACCESS_TOKEN="sbp_..." supabase functions deploy
`