Skip to main content

supabase

Use when building on Supabase as a backend over managed Postgres — wiring the supabase-js or SSR client, writing or debugging Row Level Security, cookie-based Auth, Storage buckets, Realtime, and Edge Functions, including local versus server JWT verification and the service-role key. NOT raw Postgres tuning (that is `postgresdb`).

소스 정보

저장소
ericrisco/rsc-harness
최근 소스 활동
2026년 7월 29일 23:33
감지된 SKILL.md 언어
영어
스타
110
포크
9

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

파일 탐색기
6 개 파일

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
supabase
description
Use when building on Supabase as a backend over managed Postgres — wiring the supabase-js or SSR client, writing or debugging Row Level Security, cookie-based Auth, Storage buckets, Realtime, and Edge Functions, including local versus server JWT verification and the service-role key. NOT raw Postgres tuning (that is `postgresdb`).
tags
["supabase","postgres","rls","auth","edge-functions","realtime","storage","baas"]
recommends
["postgresdb","nextjs","db-migrations","secure-coding","drizzle-orm"]
origin
risco
# Supabase — Postgres with RLS as the auth layer Supabase is a managed Postgres database wrapped in thin SDKs, a PostgREST data API, Auth, Storage, Realtime, and Deno Edge Functions. The one sentence that explains every footgun below: > **Your authorization lives in the database (Row Level Security), not in your app code.** The browser holds a key that can hit your database directly. That is safe *only* because RLS gates every row. Get that wrong and you either leak everything or see nothing. Everything in this skill is downstream of that fact. You can write SQL already — what you need is Supabase's specific abstractions and where the trust boundary sits. Use the current packages: **`@supabase/supabase-js` v2** (v1 is security-fixes only) and **`@supabase/ssr`** for server frameworks (it replaced the deprecated `auth-helpers`). --- ## The two keys (the trust boundary) Every Supabase project ships two classes of key. Confusing them is the one unrecoverable mistake. | Key | Where it lives | RLS | If it leaks | |-----|----------------|-----|-------------| | **anon** / **publishable** (`sb_publishable_...`) | Browser, public env (`NEXT_PUBLIC_*`) | **Enforced** — safe in client only because RLS gates rows | Low: attacker still bound by your policies | | **service_role** / **secret** (`sb_secret_...`) | Server only — Edge Functions, server env, never bundled | **Bypassed entirely** | Catastrophic: full read/write of all data | The new `sb_publishable_` / `sb_secret_` format is rolling out alongside the legacy anon/service_role JWTs; treat them by the same rules. **Why this matters:** the service_role key is a master key with RLS turned off. One import into a client component is a full data breach. ```ts // Bad — service_role key reachable from the browser bundle "use client"; const supabase = createClient(URL, process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY!); // leaked // Good — service_role only in server-side env, never NEXT_PUBLIC_* // server-only module / Edge Function: const admin = createClient(URL, process.env.SUPABASE_SERVICE_ROLE_KEY!); ``` --- ## Pick your client | Context | Package | Where it lives | |---------|---------|----------------| | Browser-only SPA | `@supabase/supabase-js` (`createClient`) | Client bundle, anon key | | Next.js / SvelteKit / Remix (SSR) | `@supabase/ssr` (`createBrowserClient` + `createServerClient`) | One client per context + middleware | | Server job / Edge Function | `@supabase/supabase-js` with service_role from env | Server only | Wrong client = broken sessions (no cookie refresh) or leaked keys. For SSR frameworks you need **both** an `@supabase/ssr` browser client and a server client, plus middleware — see below. --- ## Auth that actually protects Cookie-based auth in an SSR framework has one hard constraint: **Server Components cannot write cookies**, so an expired access token can only be refreshed in **middleware**. Skip the middleware and sessions silently die mid-request. The non-negotiable rule for server-side gating: **never trust `getSession()`** — it only reads the cookie, which a client can forge. What you reach for *instead* changed in late 2025. > **Default: `supabase.auth.getClaims()`.** It verifies the JWT signature locally against your > project's published public keys (`/.well-known/jwks.json`, cached on the edge and in memory) — no > network round-trip. The current SSR docs say to *"always use `supabase.auth.getClaims()` to protect > pages and user data."* This works because new projects sign tokens with **asymmetric keys by default > since 2025-10-01** (RSA or Elliptic-Curve / ECC); the private key never leaves Auth, the public key is > safe to verify with. ### getClaims vs getUser — pick by signing key + freshness need | Situation | Use | Why | |-----------|-----|-----| | New project (asymmetric keys), gating a page or route | **`getClaims()`** | Local JWK signature check, zero latency per call — the documented default | | Legacy project still on a **symmetric** JWT secret | `getClaims()` (auto-fallback) | With no public key to verify, `getClaims()` *itself* calls the Auth server — same cost as `getUser()`, so still the right default | | You must detect a **just-banned / just-deleted** user mid-session | `getUser()` | Local verification trusts a still-valid signature; only a server round-trip sees a revoked user. Higher cost, stricter guarantee | ```ts // Bad — getSession reads an unverified cookie; trivially spoofed const { data: { session } } = await supabase.auth.getSession(); if (!session) redirect("/login"); // NOT a real check on the server // Good (default) — getClaims verifies the JWT signature locally, no round-trip const { data } = await supabase.auth.getClaims(); if (!data?.claims) redirect("/login"); // Good (strict fallback) — getUser revalidates against the Auth server every call; // use only when you need live ban/delete detection const { data: { user } } = await supabase.auth.getUser(); if (!user) redirect("/login"); ``` Middleware token refresh is mandatory; full app-router code (browser client, server client, `middleware.ts`, server-action sign-in/out, OAuth/PKCE callback, and the `auth-helpers`→`@supabase/ssr` migration checklist) lives in [references/auth-ssr.md](references/auth-ssr.md). For the framework's own RSC/caching/server-action mechanics see [../nextjs/SKILL.md](../nextjs/SKILL.md) — this skill only covers the Supabase wiring inside it. --- ## RLS recipes + the performance cliff Enable RLS on **every** table the data API can reach, then write policies. Four rules that cover most real cases: 1. **`enable row level security`** on the table — without it, an exposed table is wide open to the anon key. 2. **Scope with `to authenticated`** — don't rely on `auth.uid()` alone to exclude the `anon` role; an anon request has a null uid and may slip through a sloppy predicate. 3. **Wrap `(select auth.uid())`** — Postgres caches a `select`-wrapped call once per statement instead of re-evaluating per row. Bare `auth.uid()` turns a lookup into a per-row function call → table-scan latency. 4. **Index the policy columns** — a policy filtering `user_id = (select auth.uid())` needs an index on `user_id`, or every query scans the table. ```sql -- Bad — bare auth.uid() re-evaluated per row, no role scope, no index create policy "owner reads" on documents for select using ( user_id = auth.uid() ); -- Good — cached subquery, role-scoped; pair with an index create policy "owner reads" on documents for select to authenticated using ( user_id = (select auth.uid()) ); create index on documents (user_id); ``` For cross-table checks (is the user a member of this org?), use a `security definer` helper or a `team_id` filter rather than a correlated subquery inside the policy. Multi-tenant `team_id` patterns, public-read/private-write, storage policies, `realtime.messages` policies, and how to test policies with `set role authenticated` live in [references/rls-cookbook.md](references/rls-cookbook.md). For engine-level index choice and EXPLAIN reading, see [../postgresdb/SKILL.md](../postgresdb/SKILL.md). --- ## "My query returns nothing" — checklist Silent empty results are the #1 confusion. Walk it in order: 1. **Is RLS enabled but you have no policy?** No policy = deny all. Add a `select` policy. 2. **Is the policy too strict / role-scoped wrong?** Test it: `set role authenticated;` with a faked `request.jwt.claims` (see the cookbook). 3. **Is the table even exposed to the data API?** This is the new trap. Supabase is flipping the "automatically expose new tables" default **off**: default for new projects since **2026-05-30**, and enforced on **all existing projects 2026-10-30**. An unexposed table is unreachable through PostgREST *even with perfect RLS* — you get empty results, not an error. Expose it explicitly in the dashboard (Data API settings) or grant access in the relevant schema. --- ## Storage Buckets hold objects; access is governed by RLS policies on the `storage.objects` table — the same engine as table RLS. - **Public bucket**: objects served via a stable public URL, no auth. Good for avatars, bad for anything private. - **Private bucket**: reads require a **signed URL** (`createSignedUrl`, time-limited) or an authed request that passes a policy. **Why this bites:** a "public" bucket with no upload policy is either world-writable or fully closed depending on your defaults. Always write explicit `insert`/`select` policies on `storage.objects`. ```ts // Client upload (anon key) — RLS on storage.objects decides if it is allowed await supabase.storage.from("avatars").upload(`${user.id}/photo.png`, file); // Private read — short-lived signed URL, not a public link const { data } = await supabase.storage.from("docs").createSignedUrl(path, 60); ``` --- ## Realtime — default to Broadcast Three features, and the choice between them is a scaling decision teams get wrong. | Feature | Use for | Scaling | |---------|---------|---------| | **Broadcast** | Ephemeral messages (chat, cursors, custom events) | Scales for high fan-out; preferred default | | **Presence** | Who's online / shared cursor state | Backed by Broadcast machinery | | **Postgres Changes** | WAL-based row insert/update/delete events | Does **not** fan out well at scale | > Default to **Broadcast**. Reach for Postgres Changes only for low-volume row-event needs. For row changes that must reach many clients, use **"broadcast from the database"** — a trigger that calls `realtime.broadcast_changes`/`realtime.send` — instead of Postgres Changes. Private channels are authorized by RLS policies on the **`realtime.messages`** table (Broadcast and Presence support this). ```ts const channel = supabase.channel("room:42", { config: { private: true } }); channel .on("broadcast", { event: "msg" }, ({ payload }) => render(payload)) .subscribe(); channel.send({ type: "broadcast", event: "msg", payload: { text: "hi" } }); ``` --- ## Edge Functions Edge Functions are Deno/TypeScript, deployed globally. The runtime injects `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_ROLE_KEY`. - Scaffold: `supabase functions new my-fn`. Each function may carry its own `deno.json`/`deno.jsonc` (requires CLI ≥ v1.215.0). - Secrets: `supabase secrets set MY_KEY=...` (don't commit them). - **Forward the caller's JWT** so RLS still applies: read the request's `Authorization` header and pass it into a per-request client. A function that uses the service_role client silently bypasses *all* RLS — only do that for genuinely trusted admin work. ```ts // Good — per-request client carries the user's JWT; RLS enforced const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, { global: { headers: { Authorization: req.headers.get("Authorization")! } } }, ); ``` Handle CORS for browser invocations (return `Access-Control-Allow-*` and answer the `OPTIONS` preflight). --- ## Local dev & migrations - `supabase init` → `supabase start` (local stack) → `supabase link --project-ref <ref>`. - Generate migrations from local changes with **`supabase db diff`** rather than editing prod schema in the dashboard (the dashboard drifts from source control). - **Declarative schema** (experimental): describe schema as SQL files and let `supabase db diff` generate the migration via the pg-delta diff engine. - Seed local data via `supabase/seed.sql`. For migration discipline (expand-contract, concurrent indexes, batched backfills) the general practice lives in `../db-migrations/SKILL.md`; for ORM-managed schema on top of Supabase Postgres see [../drizzle-orm/SKILL.md](../drizzle-orm/SKILL.md). Note: `pg_graphql` is disabled by default on new projects (since Feb 2026) — enable it if you need it. --- ## Anti-patterns | Anti-pattern | Why it's wrong | Do instead | |--------------|----------------|------------| | `service_role`/`sb_secret_` in a client component or `NEXT_PUBLIC_*` | Bypasses RLS → full data breach | Keep it server-only; browser uses anon/publishable | | Authorizing with `getSession()` on the server | Reads an unverified cookie; spoofable | `getClaims()` — verifies the JWT signature locally (`getUser()` only when you need live ban/delete detection) | | "I'll disable RLS for now" | An exposed table with RLS off is world-readable via the anon key | Enable RLS + write a permissive policy instead | | Bare `auth.uid()` in a policy | Re-evaluated per row → table-scan latency | `(select auth.uid())` + index the column | | Postgres Changes for chat / high fan-out | Doesn't scale; backpressure and dropped events | Broadcast (incl. broadcast-from-database triggers) | | Empty results "must be RLS" | Often the table isn't exposed to the data API (2026 default flip) | Check Data API exposure first, then policies | | Using `@supabase/auth-helpers` | Deprecated | Migrate to `@supabase/ssr` | | service_role client inside an Edge Function for normal user work | Silently bypasses RLS for everyone | Forward the caller's `Authorization` header | --- ## Verify Run `bash scripts/verify.sh` from a Supabase-backed repo. It hard-fails on `service_role`/`sb_secret_` leaking into client-reachable files and warns on `getSession()` used for gating, bare `auth.uid()` in policies, and `create table` migrations missing `enable row level security`.
GitHub에서 보기