# Supabase Edge Functions carry the mobile backend logic your client should never hold

> Move secrets, webhooks, and quota checks into Supabase Edge Functions for a thinner Expo client.
> By Dave · 2026-09-06
> Source: https://otf-kit.dev/blog/supabase-edge-functions-mobile-backend

Every Expo app eventually grows a piece of logic that does not belong on the phone. It starts innocently: a promo-code check, a usage counter, a call to a third-party API with a secret key. You wire it into the client because it is fast, and then one day you realize the secret ships inside your JavaScript bundle, the quota check can be bypassed by anyone with a proxy, and the webhook from your payment provider has nowhere safe to land. That is the moment your app needs a real server-side companion, and for Supabase-backed mobile apps the smallest honest answer is an Edge Function.

Supabase Edge Functions are server-side TypeScript functions that run on Deno-compatible infrastructure close to your users. They sit next to your Postgres database, your auth system, and your storage buckets, so they can validate, enrich, and gatekeep before anything touches your data. For a mobile team, they fill the gap between what Row Level Security can express and what a full custom backend would cost you. If you already harden auth and RLS, functions are the natural next layer — our [row-level security production checklist](/blog/supabase-rls-production-checklist) covers the database side of that story, and this post covers the compute side.

## What belongs at the edge and what stays on the client

The rule is simple: the client renders and captures intent, the edge decides and enforces. Anything involving a secret key, a money movement, a quota, or a cross-user write belongs in a function. Anything that is pure presentation, local validation, or a read the user is already authorized for can stay on the device.

Concretely, move these into Edge Functions: webhook receivers for Stripe, RevenueCat, or Resend; promo-code and referral redemption with single-use guarantees; rate-limited actions like sending invites or generating share links; aggregation endpoints that would otherwise need an over-permissive RLS policy; and any third-party API call that requires a private key. Keep on the client: form input and optimistic UI, cached reads through the Supabase client library, realtime subscriptions scoped by RLS, and file uploads signed by short-lived policies.

This split pays off twice. First, your bundle stops carrying secrets, which means a decompiled APK or an inspected bundle reveals nothing useful. Second, your security model stops depending on every client behaving honestly, because the enforcement lives where the attacker cannot reach it. The client proposes, the edge disposes.

## Shape every function like a small API contract

The biggest mistake teams make is treating functions as loose scripts. Treat each one as a versioned API contract with a fixed input shape, a fixed output shape, and explicit error codes the client can act on. Validate the payload with a schema library, reject early with a 400 and a machine-readable code, and never let an unhandled exception leak a stack trace to the phone.

```ts
// supabase/functions/redeem-promo/index.ts
import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts";

const Body = z.object({ code: z.string().min(4).max(32) });

serve(async (req) => {
  if (req.method !== "POST") {
    return Response.json({ error: "method_not_allowed" }, { status: 405 });
  }
  const parsed = Body.safeParse(await req.json().catch(() => null));
  if (!parsed.success) {
    return Response.json({ error: "invalid_code_format" }, { status: 400 });
  }
  // ... lookup, single-use check, and credit logic here
  return Response.json({ ok: true, credits: 50 });
});
```

Notice the discipline: method gate, schema parse, typed error codes. The mobile client can switch on `invalid_code_format` versus `already_redeemed` versus `expired` and show the right screen without parsing message strings. That contract is what lets your iOS and Android surfaces share one backend behavior through a single Expo codebase, and it is what keeps a future AI-assisted refactor from silently changing semantics — the same contract thinking we recommend in our [AI MVP production checklist](/blog/ship-ai-mvp-to-production-checklist).

## Keep secrets and service keys off the device

Secrets belong in the function's environment, never in `app.config.ts`, never in an EAS build profile, and never in a bundled `.env` file. When you create a function that calls a third party, the private key lives in the Supabase project secrets store and is injected at runtime. The phone only ever sees the result.

```bash
# Set once per environment, never commit to the repo
supabase secrets set STRIPE_SECRET_KEY=sk_live_... --project-ref abcxyz
supabase secrets set RESEND_API_KEY=re_... --project-ref abcxyz
```

```ts
// Read at runtime inside the function only
const stripeKey = Deno.env.get("STRIPE_SECRET_KEY");
if (!stripeKey) {
  return Response.json({ error: "server_misconfigured" }, { status: 500 });
}
```

Audit this regularly. Search your repo for `sk_live`, `sk_test`, and `service_role` outside of server-side directories, and treat any hit as a release blocker. The Supabase `service_role` key in particular must never ship in a mobile bundle: it bypasses RLS entirely, and anyone who extracts it owns your database. Functions are the sanctioned place to use it, because the key stays on infrastructure you control while the client authenticates with its ordinary user JWT.

## Verify webhooks before touching your database

Payment and lifecycle webhooks are the highest-stakes functions you will write, because they move money and entitlements. Every webhook handler needs three properties: signature verification against the provider's secret, idempotent processing keyed on the provider's event id, and a fast 200 response that acknowledges receipt before heavy work begins.

```ts
// supabase/functions/stripe-webhooks/index.ts
import { serve } from "https://deno.land/std@0.224.0/http/server.ts";

serve(async (req) => {
  const signature = req.headers.get("stripe-signature");
  const rawBody = await req.text();
  if (!signature || !(await verifyStripeSignature(rawBody, signature))) {
    return new Response("bad signature", { status: 401 });
  }
  const event = JSON.parse(rawBody);
  const { data, error } = await supabase
    .from("webhook_events")
    .upsert({ provider_id: event.id, type: event.type, payload: event }, { onConflict: "provider_id" });
  if (error) return new Response("retry later", { status: 500 });
  // Enqueue entitlement work; return fast so Stripe does not retry.
  return new Response("ok", { status: 200 });
});
```

The `webhook_events` table is the idempotency log: if Stripe retries a delivery, the upsert on `provider_id` makes the second arrival a no-op instead of a double credit. Process entitlements from that table in a second step — a database trigger or a queued job — so a slow entitlement calculation never causes the provider to time out and retry. Log every rejection with the event id, because the first time a customer says they paid but nothing unlocked, that log is your entire debugging story.

## Call functions from Expo without leaking auth

From the Expo side, invoke functions through the Supabase client so the user's JWT travels automatically. Do not hand-roll `fetch` calls with manually attached tokens unless you have a reason; the client library handles refresh and header injection, and it keeps the auth path identical to your ordinary database calls.

```tsx
// lib/promo.ts
import { supabase } from "./supabase";

export async function redeemPromo(code: string) {
  const { data, error } = await supabase.functions.invoke("redeem-promo", {
    body: { code: code.trim().toUpperCase() },
  });
  if (error) throw new Error("network_error");
  if (!data.ok) throw new Error(data.error ?? "redeem_failed");
  return data.credits as number;
}
```

Wrap every invocation in a typed helper like this one, normalize the error codes once, and let your screens consume the helper instead of the raw client. Add a timeout and one careful retry for idempotent reads, but never auto-retry non-idempotent writes like redemptions or purchases — a retried POST that actually executed twice is how users get double-charged. Handle the offline case explicitly: queue the intent locally and confirm it when connectivity returns, following the same mutation-queue discipline you would use for any write that must survive a tunnel or a flight mode toggle.

## Observe, version, and roll back with confidence

A function you cannot observe is a function you cannot trust in production. Stream invocation logs into whatever you already use for crash reporting, and emit one structured log line per invocation with the function name, the user id, the outcome code, and the duration. Alert on the rate of 5xx responses and on sudden latency shifts, not on individual failures — mobile networks produce enough transient noise that per-error paging will burn your team out.

Version deliberately. Keep each function small enough that a deploy is boring, deploy through CI with the Supabase CLI rather than from a laptop, and keep the previous working version one command away from restoration. Because the client talks to a named function over HTTPS, you can also stage behavior safely: ship the new function version, point a feature flag at it for a fraction of users, and roll the flag back if the error rate moves. That is the same rollback muscle you build for over-the-air bundles, applied to the server side where the blast radius is every platform at once.

Start this week by moving exactly one endpoint — the riskiest secret or the most-abused quota check — into a function behind a typed contract. You will feel the codebase relax: the client gets thinner, the threat model gets shorter, and the next integration takes an afternoon instead of a sprint.

## Sources

- Supabase Edge Functions documentation covering the Deno-compatible runtime, gateway auth handling, local development with the Supabase CLI, and Postgres connection guidance: [Supabase docs: Edge Functions](https://supabase.com/docs/guides/functions)
- Internal references: [row-level security production checklist](/blog/supabase-rls-production-checklist) and [ship an AI MVP to production checklist](/blog/ship-ai-mvp-to-production-checklist).