# Supabase Tutorial for Beginners: Build a Backend and Lock It Down

> Set up a Supabase project, insert and read data from Next.js, then actually turn on Row Level Security so strangers can't read your users' rows.

- Author: Vibe Coders PH Team
- Published: 2026-09-25
- Category: Web Development
- Tags: Supabase, Web Development, Databases, Security, Next.js, Beginners
- Canonical URL: https://www.vibecoders.ph/blog/supabase-tutorial-for-beginners
- Publisher: Vibe Coders PH (https://www.vibecoders.ph)

> **Short answer:** Supabase gives you a hosted Postgres database, auth, and an instant API in a few clicks, and you talk to it from your app with the `supabase-js` client and your project's anon key. That key is meant to be public, but it only stays safe if you enable Row Level Security (RLS) on every table and write policies that say who can read or write which rows. Skip that step, which Supabase disables by default outside its dashboard, and any table in your public schema is readable and writable by anyone who has your anon key, which is baked into your frontend and trivially visible to anyone who opens dev tools.

A lot of vibe-coded apps get the "build a backend" part right and the "don't leak everyone's data" part wrong. That second part isn't optional, and it isn't hard once you know where the switch is. This walks through creating a Supabase project, wiring it into a Next.js app, and then locking the data down properly with RLS, which is the part most tutorials skip or rush.

## What is Supabase, and why do vibe coders reach for it?

Supabase is an open-source backend platform built on Postgres. One project gives you a full relational database, authentication, file storage, and an auto-generated REST and realtime API, without you writing a backend server yourself. For a beginner or someone shipping fast with AI tools, that means you can go from "I have an idea" to "users can sign up and save data" in an afternoon, which is why it shows up constantly in [our AI engineer survival guide to Supabase and MCP](/blog/ai-engineer-survival-guide-supabase-mcp-vibe-coding) and in projects from our [AI Builder Cohort](/ai-builder-cohort).

## Step 1: Create a project and a table

1. Go to [supabase.com](https://supabase.com) and create a free account, then click **New Project**. Pick a name, a database password (save it somewhere, you'll need it for direct Postgres connections), and a region close to your users.
2. Once the project finishes provisioning, open the **Table Editor** and create a table. For this walkthrough, make a `notes` table with columns: `id` (uuid, primary key, default `gen_random_uuid()`), `user_id` (uuid, references `auth.users`), `content` (text), and `created_at` (timestamptz, default `now()`).
3. Grab your project's API credentials from **Project Settings > API**: the **Project URL** and the **anon / publishable key**. You'll also see a **service_role / secret key** here. Note it, don't use it yet.

## Step 2: Connect it to a Next.js app

Install the client library and set your environment variables.

```bash
npm install @supabase/supabase-js
```

```bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY
```

Create a single client you can import anywhere in the app:

```typescript
// lib/supabase.ts
import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
```

Now insert and read rows from a client component:

```typescript
// Insert a note tied to the logged-in user
const { data, error } = await supabase
  .from("notes")
  .insert({ content: "First note", user_id: user.id })
  .select();

// Read the current user's notes
const { data: notes, error: readError } = await supabase
  .from("notes")
  .select("*")
  .eq("user_id", user.id);
```

At this point the app works. It also has a hole big enough to drive a jeepney through, which is the next section.

## Step 3: Understand why the anon key being public is fine, and why that's not the whole story

The `NEXT_PUBLIC_` prefix in Next.js means that key gets bundled straight into the JavaScript your browser downloads. Anyone can open dev tools, look at the network tab, and copy it out. Supabase's own docs confirm this is expected: the anon (publishable) key is designed to ship in browser code, and it is only ever as safe as the Row Level Security policies sitting behind it. The key identifies your project. It does not, by itself, decide who can see what. Policies do that.

The problem is what happens before you write any policies. Supabase's documentation is explicit that a table in an exposed schema without RLS enabled is readable and writable by any role with a grant on it, meaning that fresh `notes` table above is currently open to anyone with your anon key, which, again, is public. This is the single most common security mistake in vibe-coded Supabase apps.

## Step 4: Enable Row Level Security

Run this in the Supabase SQL Editor, or toggle "Enable Row Level Security" on the table in the Table Editor:

```sql
alter table public.notes enable row level security;
```

The moment you run that, the table flips from "open to everyone" to "open to no one," including you, until you add policies. Supabase's docs are direct about this: enabling RLS with no policies denies everything, an empty policy set means allow nothing, not allow all. That's the safe default you want, not a bug.

## Step 5: Write a policy so users can only read their own rows

A policy is effectively a `WHERE` clause that Postgres appends to every query automatically, using the built-in `auth.uid()` function, which returns the ID of whoever is making the authenticated request.

```sql
-- Users can only see their own notes
create policy "Users can view their own notes"
on public.notes
for select
to authenticated
using ( (select auth.uid()) = user_id );

-- Users can only insert notes as themselves
create policy "Users can insert their own notes"
on public.notes
for insert
to authenticated
with check ( (select auth.uid()) = user_id );

-- Users can only update or delete their own notes
create policy "Users can update their own notes"
on public.notes
for update
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users can delete their own notes"
on public.notes
for delete
to authenticated
using ( (select auth.uid()) = user_id );
```

Now the same `supabase.from("notes").select("*")` call from Step 2 only ever returns rows where `user_id` matches the logged-in user, no matter what the client code asks for. The database enforces it, not your frontend.

## Step 6: Test it with the anon key, not just by clicking around

Don't just trust that it works because the UI looks right. Confirm it at the API level:

1. Log in as User A in your app, create a note, and confirm you can read it back.
2. Open an incognito window, sign up as User B, and confirm User B's note list is empty even though User A's row exists in the same table.
3. In the Supabase dashboard, go to **Authentication > Policies** and review each policy's `using` and `with check` expressions. A common bug is writing the `using` clause but forgetting `with check` on insert/update policies, which lets a logged-in user insert rows that claim to belong to someone else's `user_id`.

| Check | What you're confirming |
| --- | --- |
| Two different logged-in users see different rows | SELECT policy is scoped correctly |
| User A cannot insert a row with User B's `user_id` | INSERT policy has a `with check`, not just `using` |
| A logged-out request via the anon key returns nothing | No public/anonymous SELECT policy is accidentally wide open |
| The table shows "RLS enabled" with policies listed in the dashboard | RLS wasn't silently left off after a migration |

## The `using (true)` trap

The fastest way to make RLS pointless is to enable it and then write a policy like this:

```sql
-- Don't do this on a table with personal data
create policy "Anyone can read"
on public.notes
for select
using ( true );
```

That policy technically satisfies "add a policy so the table isn't fully locked," but `using (true)` means the condition is always true for every row, for every requester. It's functionally identical to not having RLS at all, just with extra steps. This pattern shows up constantly in AI-generated code because an AI assistant asked to "fix the permission error" will sometimes take the path of least resistance: it makes the error go away by opening the table wide, not by scoping the policy correctly. Always read the policy an AI tool writes for you before running it, and ask specifically: does this restrict access to the current user, or does it let anyone in?

## Never put the service role key in browser code

Your project also has a `service_role` (secret) key, visible in **Project Settings > API**. It bypasses RLS entirely, by design, so your own backend server or scheduled jobs can do administrative work across all rows. Supabase's documentation is unambiguous that this key must never be exposed in a browser, mobile app, or any client-side bundle, because it grants full read and write access to your entire database, ignoring every policy you just wrote.

- Never prefix its environment variable with `NEXT_PUBLIC_` (or your framework's client-exposed equivalent).
- Only reference it from server-side code: API routes, server actions, edge functions, or a backend you control.
- If you ever see it appear in a git diff, browser network tab, or client bundle, rotate it immediately from the Supabase dashboard.

| Key | Where it lives | What it can do |
| --- | --- | --- |
| anon / publishable key | Browser, mobile app, any client code | Only what your RLS policies allow, nothing more |
| service_role / secret key | Server-only: API routes, cron jobs, backend services | Bypasses RLS completely, full database access |

Supabase has also been rolling out a renamed, clearer key format (`sb_publishable_...` and `sb_secret_...`) alongside the legacy `anon`/`service_role` JWTs, with the newer secret-key format rejecting requests that carry a browser User-Agent as an extra guardrail. Check your project's API settings page for which format is currently active for your project.

## Putting it together: a realistic beginner checklist

1. Create the table.
2. Enable RLS on it immediately, before writing any app code against it. Treat this as part of creating the table, not a follow-up task.
3. Write policies scoped to `auth.uid()` for select, insert, update, and delete, matching your actual access rules.
4. Test with two separate accounts, not just one.
5. Confirm the service role key never appears in anything that ships to the browser.

If you want a deeper walkthrough on securing a Supabase-backed app you're vibe coding with AI tools, including MCP-based workflows, see [our AI engineer survival guide to Supabase and MCP](/blog/ai-engineer-survival-guide-supabase-mcp-vibe-coding). And if this is your first backend ever, our free [Ship Your First Project](/challenges/ship-your-first-project) 7-day challenge is a structured way to build and ship something real, security included.

## Frequently asked questions

### Is it safe to put my Supabase anon key in frontend code?

Yes, that's how it's meant to be used, and Supabase's own documentation confirms the anon key is designed to ship in browser code. Its safety depends entirely on whether you've enabled Row Level Security and written correct policies on every table it can reach, not on hiding the key itself.

### What happens if I enable RLS but don't write any policies?

The table becomes completely inaccessible through the API, for everyone, including your own logged-in users. Supabase's docs describe this directly: an empty policy set denies everything. You then add specific policies to open up exactly the access you intend.

### What's the difference between the anon key and the service role key?

The anon key is a public client key that only grants whatever your RLS policies allow, and it's safe to expose in browser or mobile code. The service_role key bypasses RLS entirely and must only ever be used in server-side code you control, never in anything that reaches a browser or client bundle.

### Why is `using (true)` a bad RLS policy?

It sets the row-visibility condition to always be true, which means every row is accessible to every requester regardless of who they are. On a table with personal or sensitive data, this defeats the purpose of RLS while making it look, from the dashboard, like security is turned on.

### Do I need RLS if my table has no sensitive data?

Even a table you consider low-stakes is still reachable by anyone with your public anon key once it exists in an exposed schema without RLS. It's simpler and safer to make enabling RLS with explicit policies your default habit for every table, and only skip it deliberately for genuinely public read-only reference data.

## Sources

- [Row Level Security, Supabase Docs](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [API keys, Supabase Docs](https://supabase.com/docs/guides/getting-started/api-keys)
- [Migrating to publishable and secret API keys, Supabase Docs](https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys)
- [Securing your API, Supabase Docs](https://supabase.com/docs/guides/api/securing-your-api)
- [Securing your data, Supabase Docs](https://supabase.com/docs/guides/database/secure-data)
- [Our AI engineer survival guide to Supabase and MCP](/blog/ai-engineer-survival-guide-supabase-mcp-vibe-coding)
