Skip to content
Indra on Cloud
Go back

The Browser Never Sees the Token. The Edge Function Holds It.

Tokens stay server side. The browser holds none. Browser no token here session only Supabase Edge Function Deno and TypeScript token vault server side only Social API read only signed call with token never this path: browser straight to social API

The only path with a token is edge to provider. The browser path carries a session, never a provider token.

A SaaS for influencer media kits needs follower counts, post lists and profile fields from social providers. The integration must use read only scopes, keep every provider token server side, and run inside Supabase Edge Functions in Deno and TypeScript with no separate backend. I spent Feb 2023 to Dec 2025 on a platform that ingested from about 1,000 fixed devices over MQTT into a realtime pipeline on GCP, server side in Node and Go, so the shape here is familiar: untrusted edge, sealed secrets, small functions that do one call well.

The direct version works in a demo and fails in review. The frontend holds a token, calls the provider, renders a kit. It passes a manual test because the token is valid and the payload is small. It fails the moment scopes are audited, a token is logged, or two users share one browser cache entry.

The client bundle is not a vault

Anything shipped to the browser is public. Bundled keys, tokens in local storage, tokens in a query string, tokens echoed in a JSON response for debugging, all of these end up in devtools, in a proxy log, or in a copied HAR file. For a media kit product the damage is specific: one leaked creator token lets anyone read that creator’s connected account until rotation, and the audit trail points at your app.

The fix is a boundary. The browser calls your edge function with your own auth session. The edge function reads the provider token from its own environment or vault, calls the provider, strips the response to the fields the kit needs, and returns only those fields. The token never crosses into the response, into an error message, or into a log line.

// supabase/functions/social-profile/index.ts
import { serve } from "https://deno.land/std/http/server.ts";

const ALLOWED_FIELDS = ["id", "username", "followers_count", "media_count"];

serve(async (req) => {
  if (req.method !== "GET") {
    return new Response("method not allowed", { status: 405 });
  }
  const handle = new URL(req.url).searchParams.get("handle") ?? "";
  if (!handle || handle.length > 64) {
    return new Response("bad handle", { status: 400 });
  }
  const token = Deno.env.get("SOCIAL_READ_TOKEN") ?? "";
  if (!token) {
    return new Response("not configured", { status: 500 });
  }
  const upstream = await fetch(
    `https://api.provider.example/users?handle=${encodeURIComponent(handle)}`,
    { headers: { Authorization: `Bearer ${token}` } },
  );
  if (!upstream.ok) {
    return new Response("upstream error", { status: 502 });
  }
  const raw = await upstream.json();
  const clean: Record<string, unknown> = {};
  for (const key of ALLOWED_FIELDS) {
    if (key in raw) clean[key] = raw[key];
  }
  return new Response(JSON.stringify(clean), {
    headers: { "Content-Type": "application/json" },
  });
});

Note what is missing on purpose. No token in the request from the browser. No token in the response. No raw upstream payload forwarded. The allowlist is the product contract: the kit needs four fields, so four fields cross the boundary.

The failure this catches is the helpful log. Someone logs the upstream body during an incident and the token sits in plain text next to the handle. Redact at the edge: log the status code and the handle hash, never the header and never the body.

Read only is a scope decision before it is code

Providers gate data and actions by scope string at OAuth time. Read only scopes permit profile and media reads. Publishing scopes permit posting and deleting. Requesting both when the kit only reads is not harmless extra access. It triggers a stricter app review, it scares creators at the connect screen, and it widens every later incident from read to write.

Scope gate lives in the edge function, not in the client. Browser asks for kit Edge function scope allowlist read only deny publish Social API read, allowed Publish call denied at edge read path blocked, 403

Read requests pass to the provider. Publish requests stop at the edge with no token attached.

Enforce the allowlist in code, not in docs. The edge function holds a constant list of permitted scopes and permitted upstream paths. Any request that would need a wider scope gets a 403 without touching the provider and without touching the vault. That gives the reviewer a one screen proof: here is the list, here is the deny branch, no other path reaches the token.

const ALLOWED_PATHS = ["/users", "/media", "/insights"];
const DENIED = ["publish", "content_publish", "write", "delete"];

export function isAllowed(path: string, scope: string): boolean {
  if (DENIED.some((d) => scope.includes(d))) return false;
  return ALLOWED_PATHS.some((p) => path.startsWith(p));
}

The trade off is explicit. A narrower scope means fewer review questions and smaller blast radius, at the cost of rework if the product later needs write. That rework is the point. Adding a publish scope later should require a new review, a new consent screen, and a new vault entry, not a string change in a deployed function.

The edge function is the only backend you get

No separate backend is a constraint that helps. One runtime, one deploy path, one place where secrets live. Supabase Edge Functions run Deno, so the code is TypeScript with Web APIs, deployed with the Supabase CLI, versioned on a dev branch through fork and PR. That workflow fits because each provider gets one small function with its own env prefix.

Keep each function small enough to review in one screen: validate input, load server secret, call one upstream path, filter fields, return. Shared logic like redacted logging and scope checks lives in a shared module imported by path, not copied per function. Cold starts stay low because there is no framework, only fetch and JSON.

The failure to design for is secret sprawl. If each function reads a differently named variable set by clicks in a dashboard, rotation misses one and one creator kit goes stale. Name secrets by convention with provider and purpose, load them only where needed, and list them in the PR template.

CORS deserves one paragraph because media kit frontends call these functions directly. Allow only the app origin, allow only GET and POST as needed, and never reflect the request origin. A permissive CORS rule plus a session cookie turns a read only integration into a cross site read for any site the creator visits.

Refresh has a race you will hit on media kits

Creator tokens expire and refresh. Two kit views for the same creator can trigger refresh at the same moment. Both read the old refresh token, both ask the provider, one wins, the other gets an invalid grant because the provider rotated on first use. The loser then writes its failure over the winner’s fresh pair if the write is last write wins. The kit flaps between working and expired.

The fix is to serialize refresh per creator: acquire a short lived lock row in Postgres, recheck whether someone else already refreshed while waiting, only then call refresh, write the new pair, release. Reads that arrive during refresh serve the last cached profile with a stale flag rather than failing the page.

Do not store the full upstream history to avoid refetching. Store the kit fields, the fetched timestamp, and the token expiry, nothing else. A cache keyed by creator id absorbs the second view in the same minute without a second upstream call.

GDPR means you store the minimum and you can delete it

A media kit is personal data about a creator plus aggregates about their audience. GDPR and a data processing agreement require a reason for each field, a retention window, and a delete path that actually deletes. Pulling a full media object with captions, comments and liker ids when the kit shows counts is over collection. Filtering at the edge, as in the first code block, is the compliance mechanism, not just hygiene.

Keep a per creator record of which scopes were granted and when, which fields were fetched, and where they are stored. Deletion then walks that list: vault entry, cached kit row, logs with handle hashes. Hash handles in logs with a salted hash so an incident can be correlated without storing the handle in plain text next to the error. Work on a dev branch via fork and PR helps here because fixtures stand in for real creator tokens in tests, and no production secret ever lands in a fork.

What I would tell someone building this

Seal the token on day one, even before scopes are final. A function that proxies one read with an env secret and an allowlist teaches the deploy, CORS and logging shape. Scopes and fields can follow without moving the boundary.

Ask for the narrowest scope that renders the kit. Read only, named paths, deny list for publish strings. If a scope is not on the consent screen, the code path that needs it must not exist.

Never return or log the token, the header, or the raw body. Return filtered fields. Log status, hashed handle, and outcome. The incident you can debug from those three is the incident you designed for.

Serialize refresh per creator and cache the kit briefly. Concurrent refresh without a lock corrupts the stored pair. A short cache turns the second concurrent view into a hit instead of a second refresh.

Record what you hold so deletion is a query, not a hunt. Scopes, fields, timestamps, storage rows. If the delete path is not tested on the dev branch, it does not exist.


Share this post:

Previous Post
Nobody Typed the 10,000 Lines. The Review Belonged Upstream.
Next Post
The Device Went Quiet. Quiet Is Not a Reading.