# Supabase Auth

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



---

<!-- source: en/plugins/auth-supabase.md -->
## 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)](https://supabase.com/auth): 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 `matched`s (or one `failed`s).

```ts
// 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](/docs/routing/loaders-and-meta#authentication-in-loaders)); 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](/docs/authentication/external-idp#client-getting-the-token-to-the-api-http-and-websocket).

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

```ts
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`; `null` → `failed`. |
| `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

- [External identity providers](/docs/authentication/external-idp)
- [Auth plugin](/docs/plugins/auth)
