Supabase Auth

Supabase Auth (GoTrue) AuthStrategy — verifies Supabase-issued JWTs via the project's JWKS and maps app_metadata.tenant_id → tenantId.

@voltro/plugin-auth-supabase integrates Supabase Auth (GoTrue): an AuthStrategy (conforming to @voltro/protocol) that verifies Supabase-issued JWTs — by default against the project's JWKS endpoint (asymmetric public-key verify, no secret), or via an opt-in HS256 shared secret (jwtSecret) for legacy / self-hosted deployments that sign symmetrically and expose no JWKS — maps a server-controlled metadata claim to the framework's tenantId, and composes with other strategies (your password cookie, an API key, another IdP). It's a thin specialization of the shared jwtBearerStrategy — it adds Supabase's defaults and performs no verification of its own. Verified claims (both the server-controlled app_metadata and the user-controlled user_metadata blobs) arrive on the Subject under metadata.claims.

Install

pnpm add @voltro/plugin-auth-supabase

Wiring

Add supabaseStrategy(...) to auth.strategies in your api's app.config.ts. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one matcheds (or one faileds).

// app.config.ts
import { supabaseStrategy } from '@voltro/plugin-auth-supabase'

export default {
  type: 'api' as const,
  name: 'myApi',
  store: 'postgres' as const,
  auth: {
    strategies: [
      supabaseStrategy({
        projectRef: 'abcdef',                       // builds the hosted JWKS URL
        // OR self-hosted GoTrue — pass an explicit URL + issuer:
        // jwksUrl: 'https://my-supabase.example.com/auth/v1/.well-known/jwks.json',
        // issuer:  'https://my-supabase.example.com/auth/v1',
        // OR legacy / self-hosted Supabase signing with the shared secret
        // (HS256, no JWKS) — verify with the secret instead:
        // jwtSecret: process.env.SUPABASE_JWT_SECRET!,  // server-only credential
        // tenantClaim: 'app_metadata.tenant_id',    // default (dotted path)
        // defaultTenantId: 'public',                // fallback for single-tenant deployments
      }),
    ],
  },
}

Pass projectRef (hosted), jwksUrl (self-hosted GoTrue), or jwtSecret (legacy / self-hosted HS256 shared secret — see below). For a hosted project, the JWKS URL defaults to https://<projectRef>.supabase.co/auth/v1/.well-known/jwks.json and the issuer to https://<projectRef>.supabase.co/auth/v1. audience defaults to 'authenticated' (Supabase's role for signed-in users).

Tenant resolution maps from tenantClaim — default the dotted path app_metadata.tenant_id (a server-controlled blob users can't forge), then defaultTenantId. Apps that route tenants via user_metadata set tenantClaim: 'user_metadata.org' (same dotted-path resolution). Supply tenantIdFromClaims(claims) to derive it (returning null is a failed verdict).

cookieName defaults to null (header-only) — most Voltro apps send the token as an Authorization: Bearer header. For server-rendered / @supabase/ssr apps that keep the session in a cookie, set cookieName: 'sb-<projectRef>-auth-token'. @supabase/ssr does NOT store a raw JWT there — it stores the whole GoTrue session as a JSON envelope, by default prefixed with base64- and base64url-encoded, and (for large sessions) split across sb-<projectRef>-auth-token.0, .1, … chunk cookies. The strategy unwraps all of that — URL-decode, strip the base64- prefix + base64url-decode, concatenate chunks in order — down to the inner access_token before verifying it. The Authorization: Bearer header is always a raw JWT and is unaffected.

Strategies live on your type:'api' app — a type:'web' app has no auth middleware, so putting auth.strategies in a web app.config.ts does nothing. The web app forwards the request cookie to the api (including from ctx.query in a loader); the api's strategies verify it.

Client side — this strategy only verifies; your browser must send the token. For HTTP that's the normal Authorization: Bearer header; for live subscriptions over the WebSocket (the usual cross-origin api), pass it to mount({ apis: { api: { headers: async () => ({ authorization: 'Bearer ' + token }) } } }) — a browser can't set WS upgrade headers, so without this every subscription connects anonymous. See Client: getting the token to the api.

To accept your own password sessions AND Supabase during a migration, build the chain explicitly with composeAuthStrategies (see auth strategies):

import { composeAuthStrategies } from '@voltro/protocol'
import { voltroPasswordStrategy } from '@voltro/plugin-auth'
import { supabaseStrategy } from '@voltro/plugin-auth-supabase'

const resolve = composeAuthStrategies([
  voltroPasswordStrategy(),
  supabaseStrategy({ projectRef: process.env.SUPABASE_PROJECT_REF! }),
])

Options

Option Default Notes
projectRef The <ref> in <ref>.supabase.co. Required unless jwksUrl is set.
jwksUrl https://<projectRef>.supabase.co/auth/v1/.well-known/jwks.json For self-hosted GoTrue; takes precedence over projectRef.
jwtSecret Opt-in HS256 mode for legacy / self-hosted Supabase that signs with the shared JWT_SECRET and exposes no JWKS. Tokens are verified symmetrically (HS256); the JWKS path is not used. Server-only credential — never ship it to the browser.
issuer https://<projectRef>.supabase.co/auth/v1 Set explicitly for self-hosted.
audience 'authenticated' String or array.
cookieName null (header-only) Set to sb-<ref>-auth-token for @supabase/ssr cookie-mode apps — the JSON / base64- / chunked session envelope is unwrapped to the inner access_token.
tenantClaim 'app_metadata.tenant_id' Dotted-path.
tenantIdFromClaims (uses tenantClaim) Derive tenantId; nullfailed.
defaultTenantId (none) Fallback for single-tenant deployments.
scopesFromClaims (none) Map claims (Supabase role, or an app_metadata permissions array) → Subject.scopes.

Algorithms follow the mode — JWKS pins ['RS256', 'ES256'], jwtSecret pins ['HS256']; the two are mutually exclusive, so there is no alg-confusion downgrade surface (an RS256 setup can't be tricked into accepting an HS256-forged token). none is never accepted.

Environment variables

None read directly — pass projectRef / jwksUrl explicitly (typically from your own defineEnv-declared vars).

Security

Server-only; fails closed — a forged, expired, or wrong-audience token, or one with no resolvable tenant, yields failed, never matched. JWKS mode does public-key verify only and handles no secret. The opt-in jwtSecret mode (legacy / self-hosted) verifies HS256 with the project's shared secret — a server-side credential; keep it out of the browser bundle and source control (load it from your env). Prefer JWKS whenever the deployment offers it.

See also