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
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.jsonIf 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:
"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:
- Your Supabase project URL —
https://YOURPROJECT.supabase.co, or your custom API domain if you use one. ★ It must be the host your access tokens'issclaim actually carries: decode any access token and checkiss— if it readshttps://api.yourapp.example/auth/v1, registerhttps://api.yourapp.example(not the rawsupabase.coURL), and vice-versa. - Whether your project enforces email confirmation (Supabase Auth → "Confirm email" on, and
secure email change enabled) — this decides
email_trustedbelow. - 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:
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:
| Field | Derived / default | Meaning |
|---|---|---|
issuer | <supabase_url>/auth/v1 | The exact iss your tokens must carry. |
jwks_uri | <supabase_url>/auth/v1/.well-known/jwks.json | Where 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. |
audience | authenticated | What GoTrue stamps on every user access token (a hygiene check; isolation comes from the issuer + your keys). |
allowed_algs | RS256, ES256 | Hard-clamped to asymmetric algorithms — HS256/none are unreachable. |
max_token_age_seconds | 3600 (range 60–86400) | The freshness window — see below. |
email_trusted | false | Whether your users' real email addresses are used — see below. |
email_linking_trusted | false | Whether a real-email collision signs the user into their EXISTING account instead of a synthetic one — see below. |
capped_role | viewer | The Busymate DevTools role clamp — see below. |
| rate limits | 30/min /start, 60/min /exchange | Per-client + per-IP token buckets. |
enabled | true | The 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.
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:
// 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;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_tokenis the assertion field for this profile — your user's live GoTrue access token (session.access_token). Passing it as anAuthorization: Bearer <token>header works too; the body field takes precedence if both are present. It must be a user token — your project'sanonkey,service_rolekey, and anonymous sign-ins are structurally rejected (none of them vouches a user).targetselects 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:
// 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.
Step 3 — hand off: open the link with the verifier in the fragment
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:
// 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:
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:
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):
| Check | Requirement |
|---|---|
| Signature | Validates against your registered JWKS (or the pinned key set). Algorithms are clamped to ES256/RS256 — an HS256 token can never pass. |
iss | Exact match with the registered issuer (<supabase_url>/auth/v1). |
exp / iat | Both required; unexpired; iat within max_token_age_seconds. |
aud | authenticated (hygiene — what GoTrue stamps on every user token). |
sub | Present and UUID-shaped — your auth.users id, which becomes the stable federated subject. |
role | Must be authenticated — your anon and service_role keys are rejected. |
is_anonymous | Must not be true — an anonymous sign-in is not a federable user. |
Used 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
| Symptom | Cause → fix |
|---|---|
Registration preflight.ok: false, signing_keys: 0 | Your 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 setup | Most 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 integration | In 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 client | Wrong client_id, or the client was disabled (the kill switch). Ask the Busymate team. |
400 on /start | Missing client_id / code_challenge, code_challenge_method ≠ "S256", or no supabase_token (body field or Authorization: Bearer). |
410 on /exchange | The code was already used (single-use), expired (60 s), or unknown — indistinguishable by design. Start again from /start. |
429 | Per-client / per-IP rate limit (fail-closed). Back off and retry. |
The user's DevTools email looks like …@federated.busymate.net | Either 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 expected | Working 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;supabaseis oneverify_modealongsidedirectus_bearerandhmac. - 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.