How to use Outseta as your auth provider and Supabase as your database, with Supabase's Row Level Security (RLS) driven by the authenticated Outseta user.
This is the updated version of this guide for Supabase projects using the new publishable API keys (sb_publishable_…). If you're on a legacy project that still uses the shared JWT secret, see the legacy guide — but we recommend migrating.
How it works
Outseta issues JWTs when users log in. Supabase's Data API (PostgREST, Storage, Realtime) can verify any JWT it trusts via its JSON Web Key Set (JWKS). Since Outseta isn't one of Supabase's first-class third-party auth providers, we bridge the two with a small Supabase Edge Function that:
-
Accepts an Outseta-signed JWT from your app
-
Verifies it against Outseta's JWKS (
https://<your-domain>/.well-known/jwks) -
Copies the claims into a new JWT, adds
role: "authenticated", and signs it with an asymmetric key your Supabase project trusts -
Returns the new JWT to your app
Your app then sends that Supabase-signed JWT in the Authorization: Bearer <jwt> header for any Supabase API call. PostgREST verifies it via the project's JWKS, and your RLS policies can match on auth.jwt() ->> 'sub' to get the Outseta Person Uid.
┌────────────┐ Outseta JWT ┌─────────────────┐ Supabase JWT ┌──────────────────┐
│ Your app │ ────────────────> │ exchange fn │ ───────────────> │ Supabase Data API│
│ │ │ (Edge Function) │ │ (RLS: sub claim) │
└────────────┘ <──────────────── └─────────────────┘ <─────────────── └──────────────────┘
Prerequisites
Outseta account:
-
A subdomain (e.g.
myapp.outseta.com) -
At least one plan family and plan defined (required before the signup embed will render)
Supabase project:
-
Created on the new API keys (publishable + secret) model
-
Dashboard access for JWT key management and edge function secrets
-
npxavailable locally (for generating a signing key)
Part 1: Create a JWT signing key in Supabase
We need an asymmetric key pair (ES256 recommended) whose private half our edge function can access and whose public half Supabase's Data API uses to verify signatures.
Unlike Supabase-managed keys (where Supabase holds the private key), we need to import a key we generated ourselves.
1. Generate the JWK locally:
npx supabase gen signing-key --algorithm ES256
You'll get JSON like this — save it in a secure location, because Supabase won't let you read the private d field back out once imported:
{
"kty": "EC",
"kid": "3a18cfe2-7226-43b0-bbb4-7c5242f2406e",
"d": "RDbwqThwtGP4WnvACvO_0nL0oMMSmMFSYMPosprlAog",
"crv": "P-256",
"x": "gyLVvp9dyEgylYH7nR2E2qdQ_-9Pv5i1tk7c2qZD4Nk",
"y": "CD9RfYOTyjR5U-PC9UDlsthRpc7vAQQQ2FTt8UsX0fY"
}
2. Import it into Supabase:
Go to Settings → JWT Keys → Create standby key. Check Import an existing private key, paste the full JWK (including the curly braces), and click Create standby key.
3. Rotate the standby key to Current:
Once the standby key appears, click Rotate keys. This makes your imported key the one PostgREST actively trusts.
Part 2: Deploy the JWT exchange Edge Function
Create a Supabase Edge Function called exchange. Full source:
// supabase/functions/exchange/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import * as jose from "https://deno.land/x/[email protected]/index.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// --- Outseta JWKS (used to verify the incoming Outseta JWT) ---
const outsetaDomain = Deno.env.get("OUTSETA_DOMAIN");
const outsetaJwks = outsetaDomain
? jose.createRemoteJWKSet(new URL(`https://${outsetaDomain}/.well-known/jwks`))
: null;
// --- Supabase signing key (used to mint the Supabase JWT) ---
// Paste the full JWK (including the private `d` field) as the env var value.
const signingJwkRaw = Deno.env.get("EXCHANGE_SIGNING_JWK");
type EcJwk = {
kty: "EC"; kid: string; crv: string; d: string;
x: string; y: string; alg?: string;
};
let signingKey: CryptoKey | null = null;
let signingKid: string | null = null;
let signingAlg: string | null = null;
if (signingJwkRaw) {
try {
const jwk = JSON.parse(signingJwkRaw) as EcJwk;
if (!jwk.kid) throw new Error("JWK missing `kid`");
const alg = jwk.alg ?? (jwk.crv === "P-256" ? "ES256" : undefined);
if (!alg) throw new Error(`Unsupported curve ${jwk.crv}`);
// ⚠️ Critical: Deno's SubtleCrypto rejects JWKs imported for signing
// without explicit key usage hints. Supabase doesn't include `use` or
// `key_ops` in the JWK it lets you export, so inject them here before
// calling importJWK, otherwise you'll get "Invalid key usage".
const jwkForImport = { ...jwk, use: "sig", key_ops: ["sign"], alg };
signingKey = (await jose.importJWK(jwkForImport, alg)) as CryptoKey;
signingKid = jwk.kid;
signingAlg = alg;
} catch (e) {
console.error("Failed to import EXCHANGE_SIGNING_JWK", e);
}
}
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (!outsetaJwks || !signingKey || !signingKid || !signingAlg) {
return new Response(
JSON.stringify({ error: "server_misconfigured" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const authHeader = req.headers.get("Authorization") ?? "";
const outsetaToken = authHeader.startsWith("Bearer ")
? authHeader.slice(7)
: "";
if (!outsetaToken) {
return new Response(
JSON.stringify({ error: "missing_token" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
try {
const { payload } = await jose.jwtVerify(outsetaToken, outsetaJwks);
const supabasePayload: Record<string, unknown> = { ...payload };
supabasePayload.role = "authenticated";
// Strip claims we want to re-issue rather than pass through.
delete supabasePayload.iat;
delete supabasePayload.exp;
delete supabasePayload.nbf;
delete supabasePayload.iss;
delete supabasePayload.aud;
const originalExp = typeof payload.exp === "number" ? payload.exp : undefined;
const supabaseJwt = await new jose.SignJWT(supabasePayload)
.setProtectedHeader({ alg: signingAlg, kid: signingKid, typ: "JWT" })
.setIssuedAt()
.setExpirationTime(originalExp ?? "1h")
.sign(signingKey);
return new Response(
JSON.stringify({ supabaseJwt, expiresAt: originalExp ?? null }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Token exchange failed", error);
return new Response(
JSON.stringify({ error: "invalid_token" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
Set the edge function secrets
In the Supabase dashboard, go to Edge Functions → Secrets and add:
|
Key |
Value |
|---|---|
|
|
Your Outseta subdomain (e.g. |
|
|
The full JWK JSON you generated in Part 1, pasted as a single string including the curly braces |
Deploy the function
npx supabase functions deploy exchange --no-verify-jwt
--no-verify-jwt is required because the JWT this function receives is Outseta's, not a Supabase Auth token — the function verifies it itself using the JWKS.
⚠️ Why the "Invalid key usage" gotcha matters. If you skip the
use: "sig"/key_ops: ["sign"]injection in the function source above,jose.importJWKwill throwInvalid key usageon every call. This isn't documented in thejosedocs and isn't in Supabase's JWK export — it's specific to how Deno's WebCrypto strictly validates JWKs that don't carry key-usage hints. Include those fields or the function will not work.
Part 3: Define your database schema with RLS
Your tables should have a user_id column of type text (not uuid — the Outseta Person Uid is a short string) and four RLS policies keyed on auth.jwt() ->> 'sub'.
Example:
create table public.todos (
id uuid primary key default gen_random_uuid(),
user_id text not null,
title text not null,
is_completed boolean not null default false,
created_at timestamptz not null default now()
);
create index todos_user_idx on public.todos (user_id);
alter table public.todos enable row level security;
create policy "select own" on public.todos
for select using (user_id = auth.jwt() ->> 'sub');
create policy "insert own" on public.todos
for insert with check (user_id = auth.jwt() ->> 'sub');
create policy "update own" on public.todos
for update using (user_id = auth.jwt() ->> 'sub')
with check (user_id = auth.jwt() ->> 'sub');
create policy "delete own" on public.todos
for delete using (user_id = auth.jwt() ->> 'sub');
Other claims available via auth.jwt() ->>:
|
Claim |
Value |
|---|---|
|
|
Outseta Person Uid |
|
|
Outseta Account Uid |
|
|
|
|
|
User's email |
For multi-tenant apps where data belongs to an account rather than a person, index and RLS on outseta:accountUid instead of sub.
Part 4: Configure Outseta in your frontend
Load the Outseta script
Outseta's script needs to initialize before any embed renders. Declare the config object before loading the script:
<script>
var o_options = {
domain: 'myapp.outseta.com',
load: 'auth,profile,nocode',
monitorDom: true,
tokenStorage: 'cookie',
auth: {
// Where users land after submitting the signup form
postRegistrationUrl: window.location.origin + '/thank-you',
// Where users land after logging in (and after email-verify flows)
authenticationCallbackUrl: window.location.origin + '/',
// Where the email-verification link points (must have Outseta loaded)
registrationConfirmationUrl: window.location.origin + '/login'
}
};
</script>
<script src="https://cdn.outseta.com/outseta.min.js" data-options="o_options"></script>
A few things that are easy to miss:
-
tokenStorage: 'cookie'is required if you want to read the JWT server-side. With any other setting, the token only lives in JS memory and isn't accessible to your backend. -
monitorDom: truemakes Outseta observe DOM mutations and hydrate embed divs (<div data-o-auth="1">) as they appear. Without it, embeds rendered by client-side frameworks like React or Vue won't initialize. -
data-options="o_options"on the script tag tells Outseta which global variable to read config from. This attribute is often omitted in older examples but is required for new-format embeds. -
The three
auth.*URL overrides replace the dashboard's corresponding Post Login / Post Sign Up / Custom Registration URL settings, resolved per-origin at page load. This lets dev / staging / prod all self-configure without dashboard edits.
The server-side cookie name
When tokenStorage: 'cookie' is set, Outseta writes the JWT to a cookie named:
Outseta.nocode.accessToken
Your backend reads this cookie to get the user's current Outseta JWT, which you then pass to the exchange function.
If you're using a framework (Next.js, SvelteKit, Nuxt, Astro, etc.), inject the two <script> tags via whatever mechanism your framework provides for the document head. The only rules are (1) o_options must be defined before the CDN script loads and (2) the CDN script must carry data-options="o_options". Most frameworks have a <Script> component or a head-injection API that handles both correctly.
Part 5: Call the exchange function and use Supabase
Once Outseta has loaded and the user is authenticated, your backend can read the Outseta JWT from the incoming request's cookie, swap it for a Supabase JWT via the exchange function, and send that Supabase JWT as the Authorization header on every Data API call.
We recommend doing this server-side. Keeping the Supabase JWT off the client means a smaller attack surface, and gating every mutation through your server gives you a natural place to add validation or business logic alongside the Supabase call.
The exchange + Supabase call (vanilla JS, any backend)
const EXCHANGE_URL = "https://<your-project-ref>.supabase.co/functions/v1/exchange";
const SUPABASE_URL = "https://<your-project-ref>.supabase.co";
const SUPABASE_PUBLISHABLE_KEY = "sb_publishable_...";
// Inside any request handler (Node, Deno, Bun, Cloudflare Workers, etc.)
async function listTodosForRequest(request) {
// 1. Read the Outseta JWT from the request's cookie header.
// Use whatever cookie-parsing helper your framework gives you.
const outsetaJwt = getCookie(request, "Outseta.nocode.accessToken");
if (!outsetaJwt) throw new Error("Not authenticated");
// 2. Exchange for a Supabase JWT.
const exchangeRes = await fetch(EXCHANGE_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${outsetaJwt}`,
"apikey": SUPABASE_PUBLISHABLE_KEY,
},
});
if (!exchangeRes.ok) throw new Error(`Token exchange failed (${exchangeRes.status})`);
const { supabaseJwt } = await exchangeRes.json();
// 3. Call the Supabase Data API with the exchanged JWT.
const res = await fetch(`${SUPABASE_URL}/rest/v1/todos?select=*`, {
headers: {
"Authorization": `Bearer ${supabaseJwt}`,
"apikey": SUPABASE_PUBLISHABLE_KEY,
},
});
return res.json();
}
Performance note: The exchanged JWT is valid for however long Outseta's original token is (typically an hour). If you're making many Supabase calls in the same request, cache the exchange result for the lifetime of the request rather than calling the exchange function each time. React's cache() is a natural fit in Next.js App Router; in other backends, a per-request memoization wrapper works fine.
Using @supabase/supabase-js
The example above uses raw fetch for transparency. If you'd rather use the Supabase JS client, pass the exchanged JWT via the accessToken option:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
accessToken: async () => {
// Called on every request; return a fresh Supabase JWT each time.
return await exchangeToken();
},
auth: { persistSession: false, autoRefreshToken: false },
});
const { data } = await supabase.from("todos").select("*");
The accessToken callback is invoked for every query, so add your own caching inside exchangeToken() (keyed by the Outseta JWT) or you'll hit the exchange function on every call.
Subscribing to auth state in the browser
Even though your data access is server-side, your UI still needs to react to login/logout on the client (to swap between "Log in" and avatar-dropdown, redirect after signup, show/hide protected UI, etc.). Subscribe to Outseta's events — pure vanilla:
// Called whenever the user logs in (or the page loads while already logged in).
window.Outseta.on("accessToken.set", async (decodedPayload) => {
const user = await window.Outseta.getUser();
console.log("Logged in as:", user.Email);
// Update your UI: show avatar, load data, etc.
});
// Called whenever the user logs out.
window.Outseta.on("accessToken.clear", () => {
console.log("Logged out");
// Update your UI: clear state, redirect, etc.
});
// To log out programmatically:
// window.Outseta.logout();
In a reactive framework (React, Vue, Svelte), wrap these subscriptions in whatever lifecycle primitive the framework provides (useEffect, onMounted, onMount) and feed the results into local state. The Outseta API itself is vanilla.
Troubleshooting
Invalid key usage when the exchange function runs. You forgot the use: "sig" + key_ops: ["sign"] injection. Deno's SubtleCrypto refuses to import the Supabase-exported JWK without those hints. See Part 2.
server_misconfigured on the exchange endpoint. One of OUTSETA_DOMAIN or EXCHANGE_SIGNING_JWK isn't set, or the JWK failed to parse. Add temporary console.error output inside the import block to see which.
The embed div is present but empty. Usually a timing issue with monitorDom: true in SPAs. You can either (a) render a placeholder div, wait for window.Outseta to exist, and then mount the embed; or (b) use a MutationObserver on your own container to confirm Outseta has injected content (useful for fade-in animations to hide the pre-render layout shift).
The signup embed renders but shows no Name field. Sign-up form fields are configured in Outseta → Auth → Sign up and Login → Sign up form fields. Add Name (or First Name + Last Name) and save.
The signup embed renders but shows no plan. You need at least one plan family + plan in Outseta → Billing. No plan, no signup.
RLS lets everything through even though policies exist. Confirm the JWT you're sending is actually the Supabase-signed one (verify its kid matches a key in your project's JWKS at https://<project>.supabase.co/auth/v1/.well-known/jwks.json). If your request arrives with the Outseta JWT directly, PostgREST won't trust it and falls back to the anon role.
After rotating the JWT signing key, old sessions fail. Wait ~20 minutes for the JWKS cache to fully propagate, or roll the previous key into "Previously used" state before revoking so both keys validate during the transition.
Related articles