@molecule/api-external-auth-supabase
Provider bond · external-auth · API (Node) · v1.0.2 · Apache-2.0
Supabase external-auth provider for molecule.dev — anon-key JWT verification (no secret needed), lazy anon/PostgREST clients, and a service-role client when the user connects SUPABASE_SERVICE_ROLE_KEY.
npm install @molecule/api-external-auth-supabasenpm · Source on GitHub · Implements @molecule/api-external-auth
How it works
@molecule/api-external-auth-supabase is a provider bond on the API (Node) side: it implements the external-auth core interface (@molecule/api-external-auth) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
// bonds.ts — wire the provider at startup:
import { setProvider } from '@molecule/api-external-auth'
import { provider } from '@molecule/api-external-auth-supabase'
setProvider(provider)
// Then server routes verify the token the frontend already sends:
import { verifyUserToken } from '@molecule/api-external-auth'
app.get('/api/me', async (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer /, '') ?? ''
const user = await verifyUserToken(token)
if (!user) {
res.status(401).json({ error: 'Invalid or expired session.' })
return
}
res.json({ userId: user.userId, email: user.email })
})Works with: @molecule/api-external-auth
Reference
External authentication provider using Supabase — first-class support for apps imported into molecule.dev that were built on Supabase (Lovable and similar).
Implements the @molecule/api-external-auth contract: the exported
provider verifies a user's Supabase JWT server-side via the anon client's
auth.getUser(token). Two tiers:
- No-secret tier (works out of the box): token verification and
RLS-constrained PostgREST access via
getAnonClient()— both using only the PUBLIC anon key (SUPABASE_ANON_KEY/VITE_SUPABASE_ANON_KEY/VITE_SUPABASE_PUBLISHABLE_KEY). No secret required. - Connected tier: once the user supplies
SUPABASE_SERVICE_ROLE_KEY,getServiceClient()returns an admin client that bypasses Row Level Security. Gate every admin path withhasServiceRole().
Settings come from configureSupabase() or, lazily at first client
creation, from env: SUPABASE_URL ?? VITE_SUPABASE_URL,
SUPABASE_ANON_KEY ?? VITE_SUPABASE_ANON_KEY ??
VITE_SUPABASE_PUBLISHABLE_KEY, SUPABASE_SERVICE_ROLE_KEY.
resetSupabase() clears cached clients + settings (test hook).
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
// bonds.ts — wire the provider at startup:
import { setProvider } from '@molecule/api-external-auth'
import { provider } from '@molecule/api-external-auth-supabase'
setProvider(provider)
// Then server routes verify the token the frontend already sends:
import { verifyUserToken } from '@molecule/api-external-auth'
app.get('/api/me', async (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer /, '') ?? ''
const user = await verifyUserToken(token)
if (!user) {
res.status(401).json({ error: 'Invalid or expired session.' })
return
}
res.json({ userId: user.userId, email: user.email })
})
// Admin operations need the service-role key — ALWAYS gate on hasServiceRole():
import { getServiceClient, hasServiceRole } from '@molecule/api-external-auth-supabase'
if (hasServiceRole()) {
const admin = getServiceClient()
await admin.from('profiles').update({ suspended: true }).eq('id', targetId)
} else {
// Degrade: store the flag in the provisioned DATABASE_URL Postgres,
// or ask the user to connect SUPABASE_SERVICE_ROLE_KEY in the
// Environment panel.
}
Type
provider
Installation
npm install @molecule/api-external-auth-supabase @molecule/api-external-auth @supabase/supabase-js
API
Interfaces
SupabaseSettings
Connection settings for the Supabase integration.
Every field is optional — anything not set explicitly via
configureSupabase falls back to environment variables, read lazily
at first client creation (never at module load):
url:SUPABASE_URL??VITE_SUPABASE_URLanonKey:SUPABASE_ANON_KEY??VITE_SUPABASE_ANON_KEY??VITE_SUPABASE_PUBLISHABLE_KEYserviceRoleKey:SUPABASE_SERVICE_ROLE_KEY
interface SupabaseSettings {
/**
* The Supabase project URL (e.g. `https://abcd1234.supabase.co`).
*/
url?: string
/**
* The PUBLIC anon (publishable) key. Safe to expose in browsers — it grants
* only what Row Level Security allows. Sufficient for verifying user JWTs.
*/
anonKey?: string
/**
* The SECRET service-role key. Bypasses Row Level Security — server-only,
* never exposed to clients. Not provisioned in molecule sandboxes by
* default; check `hasServiceRole` before relying on it.
*/
serviceRoleKey?: string
}
Functions
getAnonClient()
Returns the anon-key Supabase client (lazy singleton). Created on first
call from the resolved settings — explicit configureSupabase() values
first, env fallbacks second (SUPABASE_URL ?? VITE_SUPABASE_URL, and
SUPABASE_ANON_KEY ?? VITE_SUPABASE_ANON_KEY ??
VITE_SUPABASE_PUBLISHABLE_KEY).
The anon key is PUBLIC — this client requires no secret and is constrained
by Row Level Security. Use it for verifyUserToken() and for PostgREST
reads/writes that RLS already permits.
function getAnonClient(): SupabaseClient<any, 'public', 'public', any, any>
hasServiceRole()
Whether a service-role key is available (explicitly configured or via
SUPABASE_SERVICE_ROLE_KEY). ALWAYS check this before calling
getServiceClient() — molecule sandboxes do NOT provision the key by
default, so admin paths must degrade gracefully or ask the user to
connect it.
function hasServiceRole(): boolean
getServiceClient()
Returns the service-role (admin) Supabase client (lazy singleton). Bypasses Row Level Security — server-only, for admin operations the anon client cannot perform.
function getServiceClient(): SupabaseClient<any, 'public', 'public', any, any>
resetSupabase()
Test hook: clears the cached clients AND any settings set via
configureSupabase(), returning the module to its pristine state. The next
client creation re-reads the environment from scratch.
function resetSupabase(): void
configureSupabase(settings)
Explicitly sets (part of) the Supabase connection settings, overriding the corresponding environment-variable fallbacks.
Merges with any previously configured settings, so it can be called with
just the piece that changed (e.g. only serviceRoleKey once the user
connects it). Invalidates the cached client singletons so the next
getAnonClient() / getServiceClient() call reflects the new settings.
Use resetSupabase() for a full clean slate.
function configureSupabase(settings: SupabaseSettings): void
Constants
provider
External authentication provider backed by Supabase. Wire it at startup:
setProvider(provider) from @molecule/api-external-auth.
const provider: ExternalAuthProvider
Core Interface
Implements @molecule/api-external-auth interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/api-external-auth'
import { provider } from '@molecule/api-external-auth-supabase'
export function setupExternalAuthSupabase(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-external-auth^1.0.1
Runtime Dependencies
-
@molecule/api-external-auth -
@supabase/supabase-js -
The anon key is PUBLIC (it ships in the browser bundle of every Supabase app) —
provider.verifyUserToken()andgetAnonClient()need NO secret. Do not treat a missing service-role key as "Supabase is unusable". -
SUPABASE_SERVICE_ROLE_KEYis NOT provisioned in molecule sandboxes and never will be by default. CheckhasServiceRole()and degrade gracefully or ask the user to connect it in the Environment panel — never assume it exists. Migrations/DDL against the user's hosted Supabase database cannot run from the sandbox. -
New server-side tables belong in the provisioned
DATABASE_URLPostgres, NOT in the user's hosted Supabase — unless the user has connected Supabase credentials that allow it. -
This package is SERVER-ONLY. It throws immediately if bundled into browser/client code — import it only from server code, and never polyfill
Buffer/processto silence the guard.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- The provider bond matching the app's auth platform is wired with
setProvider()at startup — no "No external-auth provider bonded" errors appear in server logs when hitting protected routes. - Log in through the app's existing auth UI, then hit a protected API
route:
verifyUserToken()accepts the live session token and the route returns that user's data. - The counterparty is the auth platform itself: obtain a REAL user token
by signing up / logging in through the app's own UI (
interact_preview) and exerciseverifyUserToken()with the token the frontend sends — never fabricate, hand-mint, or replay a made-up token as a "valid" case. - A garbage or expired Bearer token gets a clean 401 from protected
routes (
verifyUserToken()→null) — never a 500 or a crash. - A request with no Authorization header is rejected as unauthenticated (401), not treated as a server error.
- Server-side records created for the logged-in user are keyed on the
verified
userId, and another user's token never reads them back.