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) → Behestuid. 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)
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:
supabase functions deploy behest-token3. Client
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):
-- 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:
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).
- Dashboard → Project → Auth → Add trusted issuer.
- Paste your Supabase project's JWKS URL:
https://<ref>.supabase.co/auth/v1/.well-known/jwks.json. - Map claims:
sub→uid,app_metadata.plan→tier. - 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.