Skip to main content

    Lovable + Supabase Quickstart

    Ship a chat app on Lovable.dev using Supabase auth + a Supabase Edge Function as your token minter. No dedicated backend needed.

    Prerequisites: a Lovable project (React + Vite + Supabase), a Behest project + key, Supabase CLI (npm i -g supabase) if you want to deploy from local.


    1. Configure CORS (don't skip)

    Lovable apps run on a *.lovable.app origin. Add it — plus any custom domain — to Behest dashboard → Project → Settings → Allowed Origins:

    https://your-project.lovable.app
    https://your-custom-domain.com
    http://localhost:5173
    

    Click Save. Without this, the browser blocks every call with a CORS error before it reaches Behest.


    2. Store the key in Supabase

    Supabase → Project → Settings → Edge Functions → Secrets:

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

    BEHEST_KEY is never exposed to the browser. Swap the prefix to behest_pk_ plus BEHEST_KID/BEHEST_TENANT_ID/BEHEST_PROJECT_ID for local-signing mode — no code change.


    3. Create a token Edge Function

    bash
    supabase functions new behest-token
    ts
    // supabase/functions/behest-token/index.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 }));
     
      try {
        const { token, ttl, sessionId, expiresAt } = await behest.auth.mint({
          user_id: user.id, // Supabase UUID → Behest uid (1-to-1)
          tier: user.user_metadata?.tier ?? 1,
          ttl: 900,
        });
        // Return the base URL the token was minted against so the browser
        // never has to hardcode or env-configure it. Single source of truth:
        // the BEHEST_BASE_URL secret set in step 2.
        const baseUrl = Deno.env.get("BEHEST_BASE_URL")!;
        return cors(
          new Response(
            JSON.stringify({ token, ttl, sessionId, expiresAt, baseUrl }),
            {
              headers: { "Content-Type": "application/json" },
            }
          )
        );
      } catch (err) {
        return cors(new Response(String(err), { status: 500 }));
      }
    });
     
    function cors(res: Response) {
      // Must be set in production — no wildcard on an auth-minting endpoint.
      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;
    }

    Deploy:

    bash
    supabase functions deploy behest-token

    4. Lovable frontend

    In Lovable, add a chat component. The browser uses the OpenAI SDK directly with the minted JWT — no Behest SDK in the browser. The Edge Function returns the base URL alongside the token, so the frontend has nothing to configure (Lovable's Project settings doesn't expose a generic env-vars UI — and it doesn't need to).

    tsx
    // src/Chat.tsx
    import { useRef, useState } from "react";
    import OpenAI from "openai";
    import { supabase } from "@/integrations/supabase/client";
     
    type TokenBundle = {
      token: string;
      ttl: number;
      sessionId: string;
      expiresAt: number;
      baseUrl: string; // returned by the Edge Function from BEHEST_BASE_URL
    };
    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 default function Chat() {
      const [messages, setMessages] = useState<
        { role: "user" | "assistant"; content: string }[]
      >([]);
      const [input, setInput] = useState("");
      const abortRef = useRef<AbortController | null>(null);
     
      async function send() {
        if (!input.trim()) return;
        const next = [...messages, { role: "user" as const, content: input }];
        setMessages([...next, { role: "assistant", content: "" }]);
        setInput("");
     
        abortRef.current = new AbortController();
        const { token, sessionId, baseUrl } = await getBehestToken();
     
        const openai = new OpenAI({
          apiKey: token,
          baseURL: `${baseUrl}/v1`,
          dangerouslyAllowBrowser: true, // safe: 15-min, per-user JWT
          defaultHeaders: { "X-Session-Id": sessionId },
        });
     
        const stream = await openai.chat.completions.create(
          { messages: next, stream: true },
          { signal: abortRef.current.signal }
        );
        for await (const chunk of stream) {
          const delta = chunk.choices[0]?.delta?.content ?? "";
          setMessages((m) => {
            const copy = [...m];
            copy[copy.length - 1] = {
              role: "assistant",
              content: copy[copy.length - 1].content + delta,
            };
            return copy;
          });
        }
      }
     
      return (
        <div className="p-4">
          {messages.map((m, i) => (
            <div key={i}>
              <b>{m.role}:</b> {m.content}
            </div>
          ))}
          <input
            className="border p-2 w-full"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && send()}
          />
          <button onClick={() => abortRef.current?.abort()}>Stop</button>
        </div>
      );
    }

    5. Map Supabase users → Behest users

    Supabase auth.users.id (UUID) is passed straight through as Behest uid. Rate limits, sessions, threads, and usage analytics are all per-user automatically — no extra mapping table needed.

    See integrations/supabase.md for RLS patterns and tier syncing.


    Next steps

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

    Learn more