← All posts
·13 min read

Claude Code with Supabase Auth: Close the RLS Gap

Claude CodeSupabaseAuthenticationSecurity
Claude Code with Supabase Auth: close the RLS gap

Why Supabase Auth without CLAUDE.md ships a database anyone can read

Supabase Auth is the authentication layer of Supabase: it issues JWTs on login, manages sessions, handles social and email providers, and, critically, integrates with Postgres row-level security so the database itself decides which rows a user can see. The appeal is that authorization lives in the database as policies, so a single mistake in application code cannot expose data the policy forbids. The risk is that this only holds if the policies are actually written and if application code uses the user's token rather than a key that bypasses every policy, and that bypass is exactly what an assistant generating "Supabase auth code" reaches for when a query returns empty.

A Claude Code session that wires up Supabase Auth without project rules produces code that logs a user in and reads their data, which looks complete. What it skips is the layer that makes the data safe. It creates a table with no RLS policy, so the table is either fully open or fully closed depending on a setting Claude does not think about. When a query returns nothing because no policy grants access, Claude "fixes" it by switching the client to the service role key, which bypasses RLS entirely and exposes every row to every request. It reads the session from a place that can be spoofed instead of the verified cookie. It trusts the user_metadata field, which the user can edit, for an authorization decision. Each of these turns the database from a guarded store into an open one, and none of it fails a login test.

This guide covers the CLAUDE.md configuration that locks Claude Code into Supabase Auth patterns where the database enforces access: RLS enabled and policied on every table, a hard line between the anon key and the service role key, server-side sessions where the verified cookie is the source of truth, authorization read only from trusted claims, and permission hooks that keep destructive database operations out of an automated session. For the broader database integration, Claude Code with Supabase covers the client, migrations, and edge functions. For a hosted identity alternative, Claude Code with Clerk covers the patterns for a managed auth layer.

The Supabase Auth CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Supabase Auth integration it needs to declare: the project URL and key policy, the RLS stance, the session model, the claim-trust rules, and the hard rules that block the bypasses Claude reaches for most often.

# Supabase Auth rules

## Keys (MANDATORY)
- Anon key: client-safe, used in browser and user-context requests
- Service role key: server-side ONLY, bypasses RLS, NEVER in a request path
- Service role key NEVER imported into browser or edge-runtime user code
- Read keys from env, NEVER hardcode either key

## Row-level security (MANDATORY)
- EVERY table has RLS ENABLED, no exceptions
- EVERY table has at least one explicit policy, never rely on default-deny alone
- Policies use auth.uid() to scope rows to the authenticated user
- A new table is NOT done until its RLS policies exist

## Sessions (MANDATORY)
- Server reads the session from the verified cookie via the SSR client
- NEVER trust a user id sent in a request body or header
- getUser() (verified) for auth decisions, NOT getSession() alone on the server
- Cookie handling goes through @supabase/ssr, NEVER hand-rolled

## Claims (MANDATORY)
- Authorization reads from app_metadata (server-controlled), NEVER user_metadata
- user_metadata is user-editable, treat it as untrusted input
- Custom roles set via a trusted server path or a Postgres function

## Hard rules
- NEVER use the service role key to work around a failing RLS policy
- NEVER ship a table without RLS enabled and a policy
- NEVER read authorization data from user_metadata
- NEVER trust a user id from the client for a data query
- NEVER call getSession() on the server without getUser() verification
- ALWAYS scope queries with auth.uid() in the policy, not a WHERE in app code
- ALWAYS use the anon key for user-context requests so RLS applies
- ALWAYS write RLS policies in the same migration as the table

Four rules here prevent the majority of the data-exposure gaps Claude generates without them.

The service role rule is the one that does the most damage when broken. The service role key bypasses every RLS policy by design, because it is meant for trusted server tasks like background jobs. When a user-context query returns empty because the policy is missing or wrong, the fastest "fix" is to swap in the service role key, and the query starts returning rows, all of them, for every user. Claude takes this shortcut readily because it makes the immediate error disappear. The rule forbids the service role key in any request path and confines it to explicitly trusted server tasks.

The RLS-default rule prevents the silent-open table. A Supabase table without RLS enabled is readable by anyone with the anon key. A table with RLS enabled but no policy is fully closed, which Claude then "fixes" with the service role key per the bug above. The rule requires both RLS enabled and an explicit policy in the same migration as the table, so a table is never in either dangerous state.

The claim-trust rule prevents privilege escalation. Supabase stores user_metadata, which the user can edit through the client, and app_metadata, which only a server with the service role can change. Claude tends to read a role from user_metadata because it is the obvious field, which lets any user grant themselves admin. The rule requires authorization data to come only from app_metadata or a trusted Postgres function.

Install and project structure

Install the SSR helpers and the client. A typical full-stack app uses the SSR package for cookie-based sessions and the core client for browser interactions.

# SSR session helpers (server cookie handling)
npm install @supabase/ssr

# Core client
npm install @supabase/supabase-js

Keep the clients in separate modules so Claude never imports the service role client into user-facing code.

src/
  lib/
    supabase/
      browser.ts       , anon-key client for the browser
      server.ts        , anon-key SSR client, reads the cookie
      admin.ts         , service-role client, trusted tasks ONLY
  middleware.ts        , refreshes the session cookie
supabase/
  migrations/          , table + RLS policy in the same file

The split keeps the trust boundary visible. The browser and server clients use the anon key, so every query they make is subject to RLS. The admin client in admin.ts holds the service role key and is imported only by background jobs and webhooks that Claude can see are not request handlers. The migrations folder is where a table and its policies live together, so a table is never created without its rules.

RLS as the trust boundary

The single most important property of a Supabase Auth integration is that the database enforces access through RLS. The pattern is to enable RLS and write a policy in the same migration that creates the table.

-- supabase/migrations/0001_posts.sql
create table posts (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null default auth.uid(),
  title text not null,
  body text
);

alter table posts enable row level security;

create policy "users read own posts"
  on posts for select
  using (auth.uid() = user_id);

create policy "users insert own posts"
  on posts for insert
  with check (auth.uid() = user_id);

Three things make this safe. The enable row level security turns on the policy engine for the table, so no row is readable without a policy that grants it. The select policy scopes reads to rows where auth.uid() matches the owner, so a user sees only their own posts no matter what query the application sends. The insert policy's with check prevents a user from creating a post owned by someone else. Because authorization lives in the policy, application code cannot widen access, which is the property the CLAUDE.md exists to protect.

The policy uses auth.uid(), the function Supabase populates from the verified JWT, so the scoping is tied to the authenticated user rather than a value the client sends. There is no WHERE user_id = ? in application code that a bug could omit. For the migration and schema patterns, Claude Code with Supabase covers the workflow.

Server-side sessions from the verified cookie

On the server, the session must come from the verified cookie, and auth decisions must use the verified user rather than the raw session. The SSR client handles the cookie, and getUser() verifies the JWT against Supabase.

// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,   // anon key, RLS applies
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (toSet) => toSet.forEach((c) => cookieStore.set(c)),
      },
    }
  );
}

The client is constructed with the anon key, so every query it runs is subject to RLS, and it reads the session from the cookie store rather than any client-supplied value. When the server makes an authorization decision, it calls getUser(), which verifies the token rather than trusting the cached session.

import { createClient } from "../lib/supabase/server";

export async function getCurrentUser() {
  const supabase = await createClient();
  const { data, error } = await supabase.auth.getUser();
  if (error || !data.user) return null;
  return data.user; // verified against Supabase, safe for auth decisions
}

The distinction matters. getSession() returns the session as stored in the cookie without re-verifying it, which is fine for reading a display name but not for an authorization decision because a tampered cookie would pass. getUser() verifies the JWT with Supabase, so the returned user is trustworthy. The CLAUDE.md rule requiring getUser() for auth decisions closes the gap where Claude reads the unverified session and gates access on it.

Reading roles from trusted metadata

Authorization data must come from app_metadata, which only a server with the service role can write, never from user_metadata, which the user controls. The reader looks at exactly the trusted field.

// src/lib/supabase/roles.ts
import type { User } from "@supabase/supabase-js";

export function getRole(user: User): string {
  // app_metadata is server-controlled; user_metadata is user-editable
  const role = user.app_metadata?.role;
  return typeof role === "string" ? role : "user";
}

export function isAdmin(user: User): boolean {
  return getRole(user) === "admin";
}

The reader looks only at app_metadata.role and defaults to the least-privileged user role when the field is missing or malformed. Because app_metadata cannot be changed from the client, a user cannot escalate by editing their own profile. Setting the role happens server-side through the admin client or a Postgres function, both of which are trusted paths. This keeps every authorization decision dependent on data the application controls, which is exactly the property the claim-trust rule protects.

The service role key, confined

The service role key has its place: trusted server tasks that legitimately need to bypass RLS, such as a webhook that writes on behalf of any user or an admin job. The CLAUDE.md rule is that it lives in one module and is never imported by a request handler.

// src/lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";

// Service role bypasses RLS. Import ONLY in trusted server tasks
// (webhooks, cron jobs, admin scripts), NEVER in a request handler
// that serves a user.
export const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } }
);

The comment is doing real work here. It tells both Claude and a human reviewer that this client bypasses RLS and where it may be used. The persistSession: false prevents the admin client from writing a session cookie, since it operates outside any user context. When Claude needs to wire up a webhook that updates a user's subscription, it imports this client knowingly; when it is tempted to use it to fix a failing user query, the CLAUDE.md hard rule blocks the move. This is how the powerful key stays useful without becoming the default.

Permission hooks for Supabase operations

Supabase has operations that destroy data or reset auth state, and the CLI can run them from a script. Permission hooks in .claude/settings.local.json keep the destructive ones out of an automated session.

{
  "permissions": {
    "allow": [
      "Bash(npx supabase migration list*)",
      "Bash(npx supabase db diff*)",
      "Bash(npx supabase gen types*)",
      "Bash(npx supabase status*)"
    ],
    "deny": [
      "Bash(npx supabase db reset*)",
      "Bash(npx supabase db push --force*)",
      "Bash(*service_role*)",
      "Bash(psql*DROP TABLE*)"
    ]
  }
}

The allow list permits the read-only and generative operations Claude needs: listing migrations, diffing the schema, generating types, and checking status. The deny list blocks resetting the database, force-pushing migrations, any shell command that echoes the service role key, and raw DROP TABLE statements through psql. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can inspect and evolve the schema without being able to wipe data or leak the privileged key.

Common Claude Code mistakes with Supabase Auth

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. Service role key used to fix a failing query

Claude generates: swapping the anon client for the admin client when RLS returns empty.

Correct pattern: write the missing RLS policy; keep the anon key so RLS applies.

2. Table created without RLS

Claude generates: a create table with no enable row level security.

Correct pattern: enable RLS and write a policy in the same migration.

3. Role read from user_metadata

Claude generates: user.user_metadata.role for an authorization check.

Correct pattern: user.app_metadata.role, which only a trusted server can set.

4. getSession trusted on the server

Claude generates: gating access on getSession() data without verification.

Correct pattern: use getUser() so the JWT is verified before the decision.

5. User id trusted from the request

Claude generates: a query filtered by a userId from the request body.

Correct pattern: let the RLS policy scope by auth.uid(), ignore client-supplied ids.

6. Hand-rolled cookie handling

Claude generates: manual cookie parsing instead of the SSR client.

Correct pattern: route all session cookies through @supabase/ssr.

Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is Supabase auth code Claude generates correctly the first time.

Testing the Supabase Auth integration

The tests that matter for Supabase Auth are the ones that prove RLS holds: one user cannot read another user's rows, and an unauthenticated request sees nothing. A test that only checks a logged-in user reading their own data proves nothing about isolation.

import { describe, it, expect } from "vitest";
import { createClient } from "@supabase/supabase-js";

const url = process.env.SUPABASE_URL!;
const anon = process.env.SUPABASE_ANON_KEY!;

describe("posts RLS", () => {
  it("hides other users' posts", async () => {
    const userA = createClient(url, anon);
    await userA.auth.signInWithPassword({ email: "a@test.dev", password: "x" });

    const { data } = await userA.from("posts").select("*");
    const foreign = (data ?? []).filter((p) => p.user_id !== "user-a-id");
    expect(foreign).toHaveLength(0);
  });

  it("returns nothing when unauthenticated", async () => {
    const anonClient = createClient(url, anon);
    const { data } = await anonClient.from("posts").select("*");
    expect(data ?? []).toHaveLength(0);
  });
});

The first test signs in as one user and confirms the result set contains no rows belonging to anyone else, which is the isolation RLS is supposed to guarantee. The second confirms an unauthenticated client sees nothing, which catches a table that was left without RLS. Run these against a seeded test project on every change to a policy or a migration. For the broader testing setup, Claude Code with Vitest covers the configuration patterns.

Building Supabase Auth where the database enforces access

The Supabase Auth CLAUDE.md in this guide produces an integration where every table has RLS enabled with an explicit policy, the service role key never touches a request path, the server reads the session from the verified cookie and gates access with getUser(), authorization comes only from server-controlled app_metadata, and queries are scoped by auth.uid() in the policy rather than a filter in application code. The result is a database that protects its own rows, so an application bug cannot expose data the policy forbids.

The principle is the same as any security-sensitive integration with Claude Code. Supabase Auth without a CLAUDE.md produces code that logs users in while leaving tables open or reaching for the service role to silence an RLS error. The CLAUDE.md is what makes the database the trust boundary and keeps the bypass key out of the paths that serve users.

For the broader database integration including migrations and edge functions, Claude Code with Supabase covers the full workflow. For a hosted identity alternative with a managed auth layer, Claude Code with Clerk covers the patterns. For the framework-integrated session model, Claude Code with NextAuth covers auth inside a Next.js app.

Get Claudify. Ship Supabase auth where the database rejects the rows it should and returns only the ones it must.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame