Skip to content
Night Owls.dev
Jump to a page

@nightowlsdev/auth-supabase

Auth

Stateless Supabase AuthProvider that maps an already-verified Supabase session into a Night Owls AuthContext.

What it does

An AuthProvider for @nightowlsdev/core that resolves identity server-side at the request boundary (never from the request body). It uses the anon/publishable key only (never the service_role secret) and verifies tokens via supabase.auth.getUser(jwt), a network round-trip against Supabase, rather than local-only decoding. Identity is read from the server-controlled app_metadata (never user_metadata): tenantId from tenantClaim (default org_id, falling back to 'default'), userId from user.id, capabilities from rolesClaim (default roles) when an array. Header mode (default) reads Authorization: Bearer <jwt>; cookie mode (useCookies: true) lazily imports the optional @supabase/ssr peer to read Supabase SSR cookies. Construct with supabaseAuth({ url, anonKey, ... }) and pass to a runner via createNextjsRunner({ engine, auth, storage }). A verification error or missing user returns null, yielding a 401. Also exports nightOwlsPlugin, the CLI plugin manifest.

Install

pnpm add @nightowlsdev/auth-supabase

Key exports

  • supabaseAuth
  • nightOwlsPlugin
  • SupabaseAuthOpts (type)

Usage

auth-supabase.ts
import { supabaseAuth } from "@nightowlsdev/auth-supabase";
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";

const auth = supabaseAuth({ url: process.env.SUPABASE_URL!, anonKey: process.env.SUPABASE_ANON_KEY! });

const runner = createNextjsRunner({ engine, auth, storage });

What it provides

auth-supabase is the AuthProvider for @nightowlsdev/core that turns an already-verified Supabase session into a Night Owls AuthContext ({ tenantId, userId, capabilities, audiences?, orgScope? }). It is a stateless verifier that holds no session: each request it validates the token against Supabase (a network round-trip via supabase.auth.getUser), then reads identity from the server-controlled app_metadata — never from user_metadata, never from the request body. Wire it into a runner and identity is resolved once, server-side, at the request boundary.

When to use it

  • Your app already signs users in with Supabase Auth and you want that identity to govern a Night Owls swarm.
  • You want real per-request validation (getUser is a round-trip against Supabase, so a revoked or expired token is actually caught) rather than a stale local decode.
  • You need multi-tenant isolation derived from the resource a call targets, not a static JWT claim — the resolveTenant seam does that fail-closed.
  • You are using the FR-055 sub-org scope or FR-031 audiences axes and want the caller's scope/audiences resolved from trusted app_metadata claims.

When not to

  • You do not use Supabase Auth — reach for auth-auth0 (Auth0 access tokens) or auth-jwt (your own signed tokens).
  • You want purely local, offline verification with no per-request network hop — verify the token cryptographically with auth-jwt (HS256 secret or the JWKS path) instead.
  • You need to revoke a live session before it expires (logout / log-out-everywhere / admin kill) — that is a separate seam; add auth-sessions.

Alternatives

  • auth-auth0Your IdP is Auth0. It verifies the access token cryptographically against Auth0's public JWKS with no per-request round-trip — lower latency, but no revocation check.
  • auth-jwtYou want no vendor SDK and no network hop: verify a Supabase access token locally with verifyJwtWithJwks (or sign your own session), assembling the AuthProvider yourself.
  • A custom AuthProviderYour identity lives somewhere neither adapter covers. AuthProvider is a one-method interface (authenticate(req) => AuthContext | null); implement it directly.

Strengths

  • Two-key discipline: uses the anon / publishable key ONLY, never the service_role secret (that key lives exclusively in storage-supabase, the authorization boundary) — so auth and storage stay separate seams.
  • Reads identity from the server-controlled app_metadata, never the user-editable user_metadata, so tenancy and roles cannot be forged by the end user.
  • getUser validates against Supabase on every request, so an invalidated token is rejected rather than trusted from a local decode.
  • resolveTenant derives AND verifies the tenant per-request from the targeted resource, fail-closed (a null rejects, there is no 'default' fallback on that path).
  • Header mode is the default; cookie mode lazily imports the optional @supabase/ssr peer only when useCookies is set.

Limits & trade-offs

  • Every request pays a network round-trip to Supabase (getUser) — added latency, and a Supabase outage couples to your auth path (the provider distinguishes an outage, which propagates, from a bad credential, which returns null / 401).
  • It maps identity; it does not manage sessions. There is no revocation here — pair auth-sessions for logout / admin kill.
  • Requires @supabase/supabase-js as a direct dependency, and cookie mode needs the optional @supabase/ssr peer installed.
  • Supabase-specific: the claim mapping and the two-key model assume Supabase Auth's app_metadata shape.

How it works

supabaseAuth({ url, anonKey, ... }) returns an AuthProvider built around an anon-key client. In header mode (default) authenticate parses Authorization: Bearer <jwt> and calls supabase.auth.getUser(jwt) — a real validation against Supabase, not a local decode. In cookie mode it lazily imports @supabase/ssr and reads the SSR cookies. From the verified user it builds the AuthContext: tenantId from tenantClaim (default org_id, falling back to 'default'), userId from user.id, capabilities from rolesClaim (default roles, when an array), plus the FR-031 audiences and FR-055 org_scope claims (a present-but-malformed scope REJECTS the request). If you pass resolveTenant, the claim path is replaced entirely by a resource-derived one that fails closed on a null or a blank tenant. A verification error or missing user returns null, which the runner turns into a 401.

Examples

Wire it into a Next.js runner

Verify with the anon key; the runner resolves identity server-side at the request boundary.

auth-supabase-example-1.ts
import { supabaseAuth } from "@nightowlsdev/auth-supabase";
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";

const auth = supabaseAuth({
  url: process.env.SUPABASE_URL!,
  anonKey: process.env.SUPABASE_ANON_KEY!, // anon/publishable key ONLY — never service_role
});

const runner = createNextjsRunner({ engine, auth, storage });

Derive the tenant from the targeted resource (fail-closed)

resolveTenant replaces the static claim: a null rejects, with no 'default' fallback.

auth-supabase-example-2.ts
import { supabaseAuth } from "@nightowlsdev/auth-supabase";

const auth = supabaseAuth({
  url: process.env.SUPABASE_URL!,
  anonKey: process.env.SUPABASE_ANON_KEY!,
  secretKey: process.env.SUPABASE_SECRET_KEY, // privileged client for the membership query only
  async resolveTenant(user, req, sb) {
    const projectId = req.headers.get("x-project-id");
    if (!projectId) return null; // fail closed — never a default tenant on this path
    const { data } = await sb.from("projects").select("organization_id").eq("id", projectId).single();
    if (!data) return null;
    return { tenantId: data.organization_id }; // userId/capabilities default off the verified user
  },
});

Doing the parts it doesn't support

  • Revoking a live session (logout / log-out-everywhere)This provider only verifies. Mint jti-bearing sessions and check them against auth-sessions' makeSessionStore.isLive on each request; revoke / revokeAllForUser kill them without shortening the token TTL.
  • Verifying a Supabase token without the SDK / network hopUse auth-jwt: verifyJwtWithJwks(token, jwks, { nowSec }) validates a modern (ES256/RS256) Supabase access token locally, or verifyJwt for the legacy HS256 secret — you fetch and cache the JWKS yourself.

Related

  • auth-auth0The sibling adapter for Auth0 access tokens — offline JWKS verification, no per-request round-trip.
  • auth-jwtVerify Supabase tokens yourself (or sign your own session) with dependency-free primitives — no SDK.
  • auth-sessionsMake the mapped identity's session revocable — logout, log-out-everywhere, admin kill.
  • coreDefines the AuthProvider / AuthContext seam this package implements.
  • org-scopeThe FR-055 sub-org scope axis this provider resolves from the org_scope claim.