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
supabase functions new behest-token// 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:
supabase functions deploy behest-token4. 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).
// 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
- Lovable prompt pack — paste into Lovable to auto-generate this whole app
- Multi-conversation chat
- Tiers and usage — show free users an upgrade CTA on 402