@nightowlsdev/auth-supabase
AuthStateless 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-supabaseKey exports
- supabaseAuth
- nightOwlsPlugin
- SupabaseAuthOpts (type)
Usage
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.
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.
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-auth0 — The sibling adapter for Auth0 access tokens — offline JWKS verification, no per-request round-trip.
- auth-jwt — Verify Supabase tokens yourself (or sign your own session) with dependency-free primitives — no SDK.
- auth-sessions — Make the mapped identity's session revocable — logout, log-out-everywhere, admin kill.
- core — Defines the AuthProvider / AuthContext seam this package implements.
- org-scope — The FR-055 sub-org scope axis this provider resolves from the org_scope claim.