Skip to content
StackSiren

How do I store secrets in Supabase Edge Functions?

By StackSiren · Published

Store provider keys and other secrets with supabase secrets set (or the Edge Function Secrets page in the Dashboard), then read them inside the function with Deno.env.get. Never put those values in front-end code, VITE_ / NEXT_PUBLIC_ variables, or a committed .env that ships with the browser bundle.

Edge Functions run on Deno away from the visitor's browser. That is the right place for OpenAI, Stripe secret, email provider and Supabase secret keys. The front end should only call supabase.functions.invoke (or your own HTTPS route) after the user is authenticated. Background: exposed API keys. Related: service_role key exposed, Are VITE_ variables secret?, Is NEXT_PUBLIC_ safe?.

Set secrets with the CLI (and Dashboard)

Production secrets are set on the remote project. Creating or deleting them needs the Owner or Administrator role; Developers can view but not change them.

# One secret at a time
supabase secrets set OPENAI_API_KEY=sk-proj-...

# Or push a local file you never commit
supabase secrets set --env-file ./.env.edge
supabase secrets list

Dashboard path (labels can move): Project → Edge Functions → Secrets (or Edge Function Secrets). Paste key/value pairs and save. Supabase's docs say a new secret is readable by deployed functions without a redeploy.

Rules that trip people up:

  • Secret names cannot start with SUPABASE_. That prefix is reserved for platform-injected variables; the Dashboard and Management API reject custom names that use it.
  • Add every real .env file to .gitignore. A committed env file is a leak waiting for a push.
  • Local and production are separate. Setting a secret in production does not put it in your laptop's Deno process.

Locally, Edge Functions read supabase/functions/.env when the local stack starts (supabase start). Create that file with the same names you use in production, ignore it in git, and if a value still comes back empty, restart or serve with an explicit file:

supabase functions serve hello-world --env-file supabase/functions/.env

A root .env that config.toml reads via env() is not automatically visible to functions — put function secrets in supabase/functions/.env (or pass --env-file).

Reading secrets in Deno

Inside a function, custom secrets are plain environment variables:

const openaiKey = Deno.env.get("OPENAI_API_KEY");
if (!openaiKey) {
  return new Response(JSON.stringify({ error: "missing_secret" }), {
    status: 500,
    headers: { "Content-Type": "application/json" },
  });
}

Never return the secret value in a response, log line, or error message. Returning configured: Boolean(openaiKey) for a smoke check is fine; echoing the key is not.

Platform keys Supabase injects (new and legacy)

Hosted Edge Functions also receive project credentials. Supabase's current secrets docs list both the newer JSON dictionaries and the legacy single keys. Hedge: confirm which pair your project still uses in the Dashboard under API settings — naming is migrating through 2026.

Newer (publishable / secret dictionaries):

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

const publishable = JSON.parse(Deno.env.get("SUPABASE_PUBLISHABLE_KEYS")!);
const secrets = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS")!);

// User-scoped client (respects RLS) — pick the key name your project uses
const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  publishable["default"],
);

// Admin client (bypasses RLS) — Edge Functions / servers only
const supabaseAdmin = createClient(
  Deno.env.get("SUPABASE_URL")!,
  secrets["default"],
);

Legacy (still documented):

  • SUPABASE_ANON_KEY — publishable / anon role
  • SUPABASE_SERVICE_ROLE_KEY — service role, bypasses RLS

Also commonly present: SUPABASE_URL, SUPABASE_DB_URL, SUPABASE_JWKS, plus hosted metadata such as SB_REGION and SB_EXECUTION_ID. Treat any secret / service-role material the same way: server only, rotate if it ever reached a browser. See Is the Supabase anon key safe? and Stripe secret key exposed for the rotate-first pattern on other providers.

Auth checks before privileged work

Moving a key into an Edge Function is not enough if anyone on the internet can invoke that function and spend your credits.

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

Deno.serve(async (req) => {
  const authHeader = req.headers.get("Authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return new Response("Unauthorized", { status: 401 });
  }

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    // publishable/anon — NOT the secret key — so RLS still applies
    Deno.env.get("SUPABASE_ANON_KEY") ??
      JSON.parse(Deno.env.get("SUPABASE_PUBLISHABLE_KEYS")!)["default"],
    { global: { headers: { Authorization: authHeader } } },
  );

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

  const providerKey = Deno.env.get("OPENAI_API_KEY")!;
  // …call the provider with providerKey, scoped to user.id…
  return Response.json({ ok: true });
});

Pattern:

  1. Require a user JWT (Authorization: Bearer …).
  2. Build a user-scoped Supabase client with the publishable/anon key plus that JWT.
  3. Reject anonymous callers before you touch the provider key.
  4. Use the secret / service_role client only for admin tasks that truly need to bypass RLS, and never for "whatever the body asks for".

Lovable and Bolt apps that already route AI calls through Supabase Functions follow the same rule: secrets stay in Function Secrets, not in the React tree. Cursor apps that deploy Edge Functions themselves should keep the same split.

Rate limits and spend controls

An authenticated endpoint can still be abused by a signed-in attacker or a stolen session.

  • Cap requests per user per minute (in-memory for a first version, or a small rate_limits table keyed by user.id).
  • Cap tokens / characters per request and reject oversized bodies early.
  • Prefer provider dashboards' hard spend limits (OpenAI, Anthropic, Stripe) so a bug cannot empty the account overnight.
  • Log user.id and a request id — not the secret, not full prompt payloads if they contain PII.

CORS is a separate concern (allowlist origins on OPTIONS); do not "fix" CORS by reflecting any Origin with credentials. Wildcard CORS is covered in Is Access-Control-Allow-Origin: * safe?.

Prove the browser is clean

  1. Search the live JS bundle for sk-, sk_live_, sb_secret_, service_role, and your provider prefixes.
  2. Confirm the front end only calls supabase.functions.invoke("your-function", { body }) (or your own API) with the user's session.
  3. Hit the function without a JWT — expect 401, not a provider success.
  4. Run StackSiren's free first scan (and the exposed API key checker) on a site you own after each redeploy.

If a secret was ever in a client build, rotate it first, then move the call. Same playbook as I leaked my OpenAI API key.

FAQ

Do I need to redeploy after supabase secrets set? Supabase's secrets docs say hosted functions pick up new secrets immediately — no redeploy required for the secret itself. You still redeploy when the function code changes.

Why can't I name a secret SUPABASE_OPENAI_KEY? Names starting with SUPABASE_ are reserved for platform-injected variables. Use something like OPENAI_API_KEY instead.

Should the function use the secret key or the publishable key? Use the publishable/anon key plus the caller's JWT for user-scoped work so RLS still applies. Use the secret / service_role key only for privileged admin paths you deliberately allow.

Local Deno.env.get returns undefined. Confirm the value is in supabase/functions/.env (not only a root .env), restart supabase start, or pass --env-file to supabase functions serve.

Is putting the OpenAI key in a Lovable / Bolt "secret" the same thing? Builder secret UIs usually feed server or Edge runtimes. Always verify the live browser bundle — if the key appears in JS, it is public regardless of the UI label. StackSiren's first scan of a site you own is free and passive.

Check your app now

Free passive scan, results in under a minute.

Run a free scan

Fix guides for this topic

Platform checklists: Lovable · Bolt · Cursor

Related posts

Sources