Skip to main content

    Supabase → Behest

    Map Supabase users to Behest users. Works for any Supabase-backed app (Lovable, hand-rolled Next.js, Flutter, iOS).

    TL;DR: Supabase auth.users.id (UUID) → Behest uid. Mint tokens in an Edge Function. Everything else — RLS, RBAC, email confirmation — is untouched.


    1. Architecture

     Browser
       ├── Supabase auth: signInWithPassword / OAuth / magic link
       │     ↓ gets Supabase session (JWT with user.id)
       ├── POST /functions/v1/behest-token (Authorization: Supabase JWT)
       │     ↓ Edge Function verifies Supabase JWT, mints Behest JWT
       │     ↓ returns { token, sessionId, ttl, expiresAt } (uid = supabase user id)
       └── POST https://{slug}.behest.app/v1/chat/completions (Behest JWT, direct from browser)
    

    No service role key in the browser. The only secret the browser holds is its own Supabase anon key.


    2. Edge Function (supabase/functions/behest-token)

    ts
    import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
    import { Behest } from "https://esm.sh/@behest/client-ts";
     
    const behest = new Behest({
      key: Deno.env.get("BEHEST_KEY")!,
      baseUrl: Deno.env.get("BEHEST_BASE_URL")!,
    });
     
    Deno.serve(async (req) => {
      if (req.method === "OPTIONS")
        return cors(new Response(null, { status: 204 }));
     
      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 cors(new Response("Unauthorized", { status: 401 }));
     
      // Optional: look up the user's paid tier from your profiles table
      const { data: profile } = await supabase
        .from("profiles")
        .select("plan")
        .eq("id", user.id)
        .single();
     
      try {
        const { token, ttl, sessionId, expiresAt } = await behest.auth.mint({
          user_id: user.id,
          tier: profile?.plan ?? 1,
          ttl: 900,
        });
        return cors(
          new Response(JSON.stringify({ token, ttl, sessionId, expiresAt }), {
            headers: { "Content-Type": "application/json" },
          })
        );
      } catch (err) {
        return cors(new Response(String(err), { status: 500 }));
      }
    });
     
    function cors(res: Response) {
      const origin = Deno.env.get("CORS_ORIGIN") ?? "";
      res.headers.set("Access-Control-Allow-Origin", origin);
      res.headers.set("Access-Control-Allow-Credentials", "true");
      res.headers.set(
        "Access-Control-Allow-Headers",
        "authorization, content-type"
      );
      return res;
    }

    Secrets (supabase secrets set):

    BEHEST_KEY=behest_sk_live_...
    BEHEST_BASE_URL=https://amber-fox-042.behest.app
    CORS_ORIGIN=https://your-app.com
    

    Deploy:

    bash
    supabase functions deploy behest-token

    3. Client

    ts
    import OpenAI from "openai";
    import { supabase } from "./supabase";
     
    type TokenBundle = {
      token: string;
      ttl: number;
      sessionId: string;
      expiresAt: number;
    };
    let cached: TokenBundle | null = null;
     
    async function getBehestToken(): Promise<TokenBundle> {
      const now = Math.floor(Date.now() / 1000);
      if (cached && cached.expiresAt - now > 60) return cached;
      const {
        data: { session },
      } = await supabase.auth.getSession();
      const r = await fetch(
        `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/behest-token`,
        {
          method: "POST",
          headers: { Authorization: `Bearer ${session?.access_token}` },
        }
      );
      if (!r.ok) throw new Error(`token fetch failed: ${r.status}`);
      cached = (await r.json()) as TokenBundle;
      return cached;
    }
     
    export async function getOpenAI() {
      const { token, sessionId } = await getBehestToken();
      return new OpenAI({
        apiKey: token,
        baseURL: `${import.meta.env.VITE_BEHEST_BASE_URL}/v1`,
        dangerouslyAllowBrowser: true, // safe: short-lived, per-user JWT
        defaultHeaders: { "X-Session-Id": sessionId },
      });
    }

    4. Syncing tier changes

    When a user upgrades via Stripe (or any billing webhook):

    sql
    -- Postgres trigger: on UPDATE profiles.plan, bump a version column.
    -- Client listens via supabase.channel() for changes, then clears its
    -- cached Behest token so the next call re-mints with the new tier.

    Or simpler: keep Behest JWT TTL at 5 min — by the time the user makes their next request, the mint function will fetch the new tier.


    5. RLS patterns

    Behest JWT is separate from Supabase JWT; Supabase RLS does not apply to Behest requests. If you want Behest to enforce a "user can only see their own threads", do nothing — Behest already scopes threads by uid from the JWT.

    If you need custom business rules (e.g., "only premium users can use claude-3-opus"), enforce them in the Edge Function before minting:

    ts
    if (requestedModel === "claude-3-opus" && profile.plan === "free") {
      return cors(new Response("Forbidden", { status: 403 }));
    }

    Or upstream in Behest's Guardrails / per-tier model allow-lists.


    6. Using Supabase as a Behest user identity source via JWKS

    Alternative path if you want zero Edge Function hops: configure Behest to trust Supabase's JWKS directly (Behest Kong plugin supports multi-issuer verification).

    1. Dashboard → Project → AuthAdd trusted issuer.
    2. Paste your Supabase project's JWKS URL: https://<ref>.supabase.co/auth/v1/.well-known/jwks.json.
    3. Map claims: subuid, app_metadata.plantier.
    4. Frontend sends Supabase JWT directly: Authorization: Bearer <supabase_access_token>.

    Skip this for apps that need tier overrides, custom uids, or session pinning — Edge Function is more flexible.


    See also

    Enterprise Token FinOps: Enforce hard budgets and attribute costs per session.

    Learn more