Federate a Supabase userbase

The partner guide for the 'supabase' federation profile — a partner whose product runs on its OWN Supabase project auto-logs (and auto-creates) its users into Busymate DevTools. The asymmetric-signing-keys (ES256/RS256 JWKS) prerequisite + the onboarding preflight, the one-admin-action registration (supabase_url / email_trusted / email_linking_trusted / capped_role / max_token_age), and the exact /start wire contract with the user's live Supabase access token as the supabase_token assertion.

Federate your Supabase userbase into Busymate DevTools

Your app runs on Supabase? Your users can sign straight into Busymate DevTools — no second signup, no password, no manual account creation. A partner whose product has its OWN Supabase project federates its whole userbase with one registration: any user who is logged into your app gets auto-logged into (or auto-created in) Busymate DevTools, vouched by the Supabase access token your app already holds.

This is the partner-facing integration guide for the supabase federation profile. It is the same one-tap hand-off documented in Open in Busymate DevTools (one-tap) — the only thing that changes is how you vouch the user at /start: instead of a Directus bearer or an HMAC signature, you send the user's live Supabase access token, and the broker verifies it against your project's public signing keys (JWKS). No shared secret ever changes hands: verification is public-key only (ES256/RS256), so Busymate never holds anything that could mint tokens for your project.

How it works in one diagram

your appPOST /startopen the hand-off linkPOST /exchangeBusymateDevToolsSupabase sessionsupabase_token + S256 challenge?code=… #fed_verifier=…code + code_verifierreal session, auto-provisioned

Under the hood the broker verifies your user's access token entirely against your registered trust anchor: the token's signature must validate against your project's published JWKS, its issuer must match your registered project URL exactly, and it must be fresh. Then the user is resolved or auto-created in Busymate DevTools, namespaced by (provider = your client_id, subject = their Supabase user id) — a stable identity that survives email changes and can never collide with, merge into, or take over an existing Busymate account.

1 · Prerequisite — asymmetric JWT signing keys

Your Supabase project must sign access tokens with an asymmetric key (ES256 or RS256). This is the one hard prerequisite.

Legacy Supabase projects sign access tokens with the symmetric HS256 shared secret (JWT_SECRET). A symmetric secret cannot be verified by a third party without escrowing it — which would hand Busymate a credential equivalent to full control of your project — so the supabase profile is deliberately JWKS-only and legacy HS256 tokens are rejected. An unmigrated project publishes a JWKS without any signing key, so there is nothing to verify against.

Check whether you're migrated — your project's JWKS must contain at least one ES256/RS256 key:

https://YOURPROJECT.supabase.co/auth/v1/.well-known/jwks.json

If the keys array contains an EC (ES256) or RSA (RS256) public key, you're ready. If it's empty (or only contains non-signing material), you're still on the legacy shared secret.

Migrate: in the Supabase dashboard for your project, open Project Settings → JWT Keys → Signing Keys and migrate from the legacy HS256 shared secret to asymmetric signing keys (pick ES256 — Supabase's current default — or RS256). Supabase runs this as a zero-downtime key migration; see Supabase's own "JWT signing keys" guide. Tokens issued after the migration are ES256/RS256-signed and verifiable against your JWKS.

The onboarding preflight checks exactly this. When Busymate registers your client, the registration runs a JWKS preflight against your project: it fetches your JWKS (or inspects the pinned key set) and asserts at least one ES256/RS256 signing key is present. The result is returned on the registration:

jsonc
"preflight": {
  "ok": true,
  "checked": "jwks_uri",
  "jwks_uri": "https://YOURPROJECT.supabase.co/auth/v1/.well-known/jwks.json",
  "signing_keys": 1
}

A failed preflight warns but does not block — the registration still saves — but every login attempt will fail closed (401) until your project actually publishes an asymmetric signing key.

2 · Register your client — one admin action

There is no per-partner code on Busymate's side. Everything specific to your app lives in one registry row, created once by a Busymate operator (the client registry is managed on the dashboard's Federation settings page and via the matching admin tools). To onboard, give the Busymate team:

  1. Your Supabase project URLhttps://YOURPROJECT.supabase.co, or your custom API domain if you use one. ★ It must be the host your access tokens' iss claim actually carries: decode any access token and check iss — if it reads https://api.yourapp.example/auth/v1, register https://api.yourapp.example (not the raw supabase.co URL), and vice-versa.
  2. Whether your project enforces email confirmation (Supabase Auth → "Confirm email" on, and secure email change enabled) — this decides email_trusted below.
  3. The role your users should get in Busymate DevTools (capped_role).

The operator registers the client with verify_mode: "supabase" — the copy-paste form of that admin action:

jsonc
upsert_federation_client {
  "client_id": "bm_yourapp",            // must be bm_<slug>  (^bm_[a-z0-9_]+$)
  "client_name": "Your App",
  "verify_mode": "supabase",
  "supabase_url": "https://YOURPROJECT.supabase.co",  // MUST match your tokens' iss host
  "email_trusted": true,                // ONLY if your project enforces email confirmation
  "capped_role": "user"                 // the role every federated user is clamped to
}

Everything else derives automatically — the row is self-describing:

FieldDerived / defaultMeaning
issuer<supabase_url>/auth/v1The exact iss your tokens must carry.
jwks_uri<supabase_url>/auth/v1/.well-known/jwks.jsonWhere your public signing keys are fetched from (registered server-side at onboarding — never read from a token header). A pinned static_jwks key set can be registered instead.
audienceauthenticatedWhat GoTrue stamps on every user access token (a hygiene check; isolation comes from the issuer + your keys).
allowed_algsRS256, ES256Hard-clamped to asymmetric algorithms — HS256/none are unreachable.
max_token_age_seconds3600 (range 60–86400)The freshness window — see below.
email_trustedfalseWhether your users' real email addresses are used — see below.
email_linking_trustedfalseWhether a real-email collision signs the user into their EXISTING account instead of a synthetic one — see below.
capped_roleviewerThe Busymate DevTools role clamp — see below.
rate limits30/min /start, 60/min /exchangePer-client + per-IP token buckets.
enabledtrueThe kill switch — disabling takes effect on the next /start.

capped_role — the role your users get

Every user provisioned through your client is clamped to this role, and the broker ignores any role hint a token could carry — a federated user can never arrive privileged. For Supabase partners the recommended cap is user: it gives a functional first-run (pair their own devices, see their own captures, workspaces, to-dos). The stricter viewer is a read-mostly guest. An all-granting role is rejected at registration.

email_trusted — real emails vs synthetic addresses

By default (false), federated users are provisioned under a synthetic, non-routable address (…@federated.busymate.net) that is unique to your app + their user id. Their identity is the (provider, subject) pair — the email is just an internal label.

Set email_trusted: true — an admin attestation made at onboarding — only if your project enforces email confirmation (unconfirmed users can't sign in) and uses secure email change. Then the verified top-level email claim from your users' tokens is used as their real Busymate address, so their DevTools account carries the email they actually use — when it is safely claimable. If that email already belongs to a different existing Busymate account, by default the user is provisioned under the synthetic address instead — a federated login can never silently take over a pre-existing account. If you want that collision to instead sign the user into their existing Busymate account (rather than get a second, synthetic one), see email_linking_trusted below.

email_linking_trusted — sign a colliding user into their EXISTING account

By default, a real-email collision (above) always falls back to the synthetic address, even if the existing Busymate account genuinely belongs to the same person. Setting email_linking_trusted: true opts your client into a guarded exception: on a first-time login whose email_trusted-verified real email collides with an existing account, the broker may attach your bm_yourapp identity to that account and sign the user in as themselves — instead of creating a second, synthetic-addressed account.

jsonc
upsert_federation_client {
  "client_id": "bm_yourapp",
  "client_name": "Your App",
  "verify_mode": "supabase",
  "supabase_url": "https://YOURPROJECT.supabase.co",
  "email_trusted": true,               // required — linking only ever considers a TRUSTED email
  "email_linking_trusted": true,       // opt in to guarded account linking
  "capped_role": "user"
}

This only ever links into an account that is not an admin/grants_all user, and only into an account whose own identity is native — created with Busymate email/password or Sign in with Apple, never another partner's federated account. Either guard failing falls back to the same synthetic address as today — never an unguarded link. The linked account's own email, password, and role are untouched; only a new bm_yourapp identity is attached, so every later login for that user resolves straight to it (the normal returning-user path). Off (the default) is byte-identical to the pre-#523 behaviour — nothing changes for your integration unless you opt in.

max_token_age_seconds — the freshness window

Supabase access tokens carry no single-use id, so the broker bounds replay by freshness: a token is accepted only while it is unexpired and was issued (iat) within the last max_token_age_seconds (default 1 hour). Practical consequence for your app: refresh the session right before calling /start (see Step 2 below) so the token's iat is fresh. The minted DevTools session itself is additionally gated by the single-use, 60-second, PKCE-bound code — a captured token can never be replayed into a session on its own.

3 · Integrate — the four steps

The flow is the standard one-tap contract — mint PKCE, /start, hand off, /exchange — with the Supabase access token as the Step-2 credential. If you've read the one-tap guide, the only new thing here is the supabase_token field.

Step 1 — mint a PKCE pair

On the device/client (RFC 7636, S256):

  • code_verifier — a high-entropy random string, base64url encoded (32 random bytes → 43 chars, URL-safe -/_ alphabet, no padding).
  • code_challenge = BASE64URL( SHA256( code_verifier ) ), method "S256". Hash the verifier's raw ASCII bytes.

Keep the code_verifier client-side — it never goes to any server except inside the final on-device redeem.

Step 2 — POST /start with a FRESH Supabase access token

First refresh your user's session so the token is freshly issued (the iat freshness window above), then vouch them to the broker:

js
// Your app, with the user signed in to YOUR Supabase project:
const { data, error } = await supabase.auth.refreshSession();   // fresh iat
const token = data.session.access_token;
http
POST https://api.busymate.net/functions/v1/federation-broker/start
content-type: application/json
 
{
  "client_id": "bm_yourapp",
  "code_challenge": "<BASE64URL( SHA256(code_verifier) )>",
  "code_challenge_method": "S256",
  "target": "web",
  "supabase_token": "<the user's current Supabase access token>"
}
  • supabase_token is the assertion field for this profile — your user's live GoTrue access token (session.access_token). Passing it as an Authorization: Bearer <token> header works too; the body field takes precedence if both are present. It must be a user token — your project's anon key, service_role key, and anonymous sign-ins are structurally rejected (none of them vouches a user).
  • target selects the hand-off: "web" (default) lands the user in the Busymate dashboard in the browser; "ios" / "universal" produce the app Universal-Link for the native one-tap hand-off (see the one-tap guide for the app-side specifics).
  • redirect_to (optional, web) — an allowlisted path/URL to land on after sign-in.

The response carries a single-use, 60-second, PKCE-bound code on a target-aware link:

jsonc
// target "web":
{ "link": "https://dash.busymate.net/auth/federation?code=<C>", "expires_in": 60, "target": "web" }
// target "ios" / "universal":
{ "link": "https://busymate.net/app/auth/federation?code=<C>", "expires_in": 60, "target": "ios" }

At this point the broker has already verified the token's signature (against your JWKS), issuer, expiry, freshness, and claim shape — and resolved or auto-created the federated user. The link is the entire hand-off; no Busymate token ever crosses to your app.

Send the user to the returned link with your Step-1 code_verifier appended in the URL fragment — the fragment (#…) is client-only, never transmitted to a server, never logged:

js
// Web: redirect the user's browser.
window.location.assign(`${link}#fed_verifier=${encodeURIComponent(verifier)}`);
Native app hand-off (target "ios"/"universal") — same shape, via the app deep link:
bmdev://auth/federation?code=<C>#fed_verifier=<V>

Open it promptly — the code expires in 60 seconds and is consumed on first use.

Step 4 — Busymate redeems and signs the user in

You're done — this step is Busymate's side. The landing page (or the app) reads the code from the query and the verifier from the fragment, strips the fragment immediately, and redeems them:

http
POST https://api.busymate.net/functions/v1/federation-broker/exchange
content-type: application/json
 
{ "code": "<C>", "code_verifier": "<V>" }

The broker re-derives BASE64URL(SHA256(code_verifier)), matches it against the code_challenge from /start, and mints a real Busymate session for the federated user — your user lands in Busymate DevTools signed in, with login_source: "bm_yourapp". If a different Busymate account was active, the session switches in place — no manual sign-out, no second login screen.

Drop-in

The framework-free web drop-in federation-onetap.js implements Steps 1–3 (its PKCE transform is byte-identical to the broker's). For a Supabase partner, pass the access token as the bearer credential:

js
openInBusymateDevTools({ credential: { authorizationBearer: session.access_token } });

What the broker verifies — the token checklist

Every /start assertion is verified only against your registered row — nothing is ever taken from the token's own headers (no jku/x5u, no token-supplied key URLs):

CheckRequirement
SignatureValidates against your registered JWKS (or the pinned key set). Algorithms are clamped to ES256/RS256 — an HS256 token can never pass.
issExact match with the registered issuer (<supabase_url>/auth/v1).
exp / iatBoth required; unexpired; iat within max_token_age_seconds.
audauthenticated (hygiene — what GoTrue stamps on every user token).
subPresent and UUID-shaped — your auth.users id, which becomes the stable federated subject.
roleMust be authenticated — your anon and service_role keys are rejected.
is_anonymousMust not be true — an anonymous sign-in is not a federable user.
emailUsed as the real address only under email_trusted, only from the top-level claim, and only when not explicitly unverified. user_metadata (end-user-writable) is never consulted.

Any failure is an opaque 401 — the wire never reveals which check failed.

Troubleshooting

SymptomCause → fix
Registration preflight.ok: false, signing_keys: 0Your project hasn't migrated to asymmetric JWT signing keys — it still signs HS256 and publishes no signing key. Migrate (Project Settings → JWT Keys → Signing Keys), confirm your JWKS shows an ES256/RS256 key, re-run the registration or just retry a login.
Every /start returns 401 (supabase assertion rejected) after a working setupMost commonly a stale token — it expired, or its iat fell outside max_token_age_seconds. Call supabase.auth.refreshSession() immediately before /start.
401 on /start, brand-new integrationIn likely order: (1) unmigrated HS256 project (see the preflight row above); (2) issuer mismatch — the registered supabase_url doesn't match your tokens' iss host. If you use a Supabase custom API domain, tokens carry that domain in iss — the registered URL must be the custom domain, not the raw project URL (and vice-versa). Decode a token and compare iss against <registered supabase_url>/auth/v1; (3) you sent the anon / service_role key or an anonymous session instead of a signed-in user's access token.
401 unknown or disabled clientWrong client_id, or the client was disabled (the kill switch). Ask the Busymate team.
400 on /startMissing client_id / code_challenge, code_challenge_method"S256", or no supabase_token (body field or Authorization: Bearer).
410 on /exchangeThe code was already used (single-use), expired (60 s), or unknown — indistinguishable by design. Start again from /start.
429Per-client / per-IP rate limit (fail-closed). Back off and retry.
The user's DevTools email looks like …@federated.busymate.netEither email_trusted is false (the default — ask the Busymate team to attest it if your project enforces email confirmation), or their real email already belongs to a different existing Busymate account (the no-merge guard — by default a collision never silently takes over that account). Their identity and data are unaffected — the address is just the label. If the colliding account is genuinely the same person and should be signed into directly, ask the Busymate team to enable email_linking_trusted for your client (guarded — never an admin account, never another partner's federated account).
The user has less access than expectedWorking as designed: every federated user is clamped to the registered capped_role, and the broker ignores any role hint. Ask the Busymate team to raise the cap for your client if warranted.

Where this fits

  • The broker is the hosted federation service behind https://api.busymate.net/functions/v1/federation-broker — it owns the JWKS verification, the single-use code registry, and the session mint. There is no per-partner code in it; supabase is one verify_mode alongside directus_bearer and hmac.
  • The hand-off UX, the deep-link spec, the app-side specifics, and the reference implementations live in Open in Busymate DevTools (one-tap) — this page only changes the Step-2 credential.
  • The client registry is managed on the dashboard's Federation settings page; registrations are admin-gated.

For anything this page doesn't answer, ask the Busymate team.