Today: lecture + demo (55 min) then hands-on lab (105 min)
“Every task in this database — every to-do, every note, every piece of personal data — is visible to any authenticated user. Not just their own tasks. Everyone’s.”
If this were a medical records system, that’s a HIPAA violation. If it were a university system, that’s FERPA. If it were a financial app, that’s potential litigation.
This isn’t a bug in the code. The code works perfectly. This is an architectural failure.
Three separate locations, three different security boundaries. Defense in depth: every layer checks.
Defense in Depth. Client checks catch mistakes. Server checks enforce rules. RLS enforces ownership. No single layer is trusted alone.
What the user sees:
My Tasks
Title • Status • Created
Buy groceries • pending • Mar 4
Review PR • done • Mar 3
CREATE TABLE tasks (
id uuid PRIMARY KEY
DEFAULT gen_random_uuid(),
user_id uuid REFERENCES
auth.users NOT NULL,
title text NOT NULL,
status text DEFAULT 'pending',
created_at timestamptz
DEFAULT now()
);
“The screen is the spec. The spec drives the schema. The schema drives the RLS policy.”
If the dashboard shows “My Tasks,” then the tasks table must have a user_id column, and the RLS policy must filter on that column. A direct, traceable line from UX decision to database security rule.
AI principle: AI writes code layer by layer. If you don’t understand the layers, you can’t verify what the AI built — or catch the RLS it forgot to enable.
const { data, error } =
await supabase.auth.signUp({
email: 'alice@example.com',
password: 'securepassword123'
});
const { data, error } =
await supabase.auth
.signInWithPassword({
email: 'alice@example.com',
password: 'securepassword123'
});
Always write if (error) throw error; after every auth call. AI-generated auth code often skips the error check.
“Sign-up is a one-time event. Session management is ongoing.”
The session — stored as a JWT in the browser — is what your app checks on every subsequent request to decide whether this person is allowed in.
JWT (JSON Web Token): A signed, self-contained token that carries the user’s identity. Supabase creates it automatically on sign-in. Your app never stores passwords — only the token.
// Naive guard — looks correct but has a UX/security flaw
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
// Dashboard content renders here...
Navigate to /dashboard while logged out. For a fraction of a second, the dashboard is visible before the redirect fires.
Flash-of-content: Users see content they should not see, even briefly. A UX problem and a trust problem. Extremely common in AI-generated protected routes.
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getUser().then(({ data }) => {
setUser(data.user);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (!user) redirect('/login');
Loading state first, auth check second, content third. The order of these three lines is not a style preference — it is a security decision.
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from('tasks')
.insert({
title: newTaskTitle.trim(),
user_id: user.id // critical: always attach user_id
})
.select()
.single();
AI warning: AI-generated forms almost always skip input validation. Every form needs required fields checked, length limits enforced, and special characters handled safely.
import { z } from "zod";
const TaskSchema = z.object({
title: z.string().min(1).max(100),
due: z.string().datetime().optional(),
done: z.boolean().default(false),
});
const result = TaskSchema
.safeParse(formData);
if (!result.success) {
// result.error.issues[]
return { error: result.error };
}
// result.data is typed & safe
await db.insert(result.data);
| Boundary | Contract Mechanism | What It Enforces |
|---|---|---|
| Browser → Server | Zod schema | Input shape and types before processing |
| Server → Server | TypeScript interface | Function signatures and return types |
| Server → Database | SQL constraints | NOT NULL, UNIQUE, CHECK, foreign keys |
| Database → User | auth.uid() RLS | Row-level ownership enforcement |
Same idea as CSV format contract (W3) and CTOC (W4). Every boundary needs a contract. The mechanism changes; the principle doesn’t.
This chain is the backbone of everything we build today.
“Claude streams tokens as they generate. This is not just a performance trick — this is a design decision about trust. While that text appears, you know the system is working.”
We talk about context engineering for giving AI the right information. The same concept applies in reverse: giving users the right information about what the system is doing at any given moment.
Perplexity: “Searching 14 sources…” — that sentence tells you the system is not frozen, and the answer is based on something. Compare that to a blank white screen for 3 seconds.
“Great UX for AI-powered applications is context engineering for humans.”
Every loading state, every streaming token, every progress indicator is the system giving users the context they need to maintain trust.
RLS enabled + no policy = zero rows returned
Not a bug. RLS defaults to deny-all. The database would rather silently return zero rows than accidentally return the wrong rows.
Toggle RLS on in Supabase → reload the app → nothing loads. That is intentional design. Now we write the policy that opens up exactly the access we want.
CREATE POLICY "Users can only see their own data"
ON tasks
FOR SELECT
USING (auth.uid() = user_id);
auth.uid() — Supabase function that returns the current user's UUID from their JWT. Used in RLS policies to restrict rows to their owner.
The USING clause is the enforcement condition. The database evaluates this for every row, every query, automatically.
Your TypeScript code never has to remember to add a WHERE clause — the database adds it for you. Same table, same query. Alice sees Alice’s data. Bob sees Bob’s.
Your code:
SELECT * FROM tasks
WHERE user_id = current_user
Bug in code:
SELECT * FROM tasks
← forgets WHERE clause
Result: ALL data exposed
Your code:
SELECT * FROM tasks
← no WHERE clause needed
Database automatically adds:
WHERE user_id = auth.uid()
Result: only user's data,
EVEN IF your code has a bug
Code-level filtering and database-level enforcement are not redundant — they are two different lines of defense. Your code will have bugs. RLS is the safety net.
Agent uses service_role key.
Query: SELECT * FROM todos
Result: Returns ALL users' data.
Agent uses user JWT.
Same query, RLS enforces: auth.uid() = user_id
Result: Only that user's data.
Overlay Plane 2 agent locations on your architecture diagram. Every agent endpoint needs the same auth + RLS stack as every human endpoint.
CREATE POLICY "Users can insert their own data"
ON tasks FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update their own data"
ON tasks FOR UPDATE
USING (auth.uid() = user_id);
-- DELETE: users can only delete their own rows
CREATE POLICY "delete_own" ON tasks
FOR DELETE USING (auth.uid() = user_id);
USING vs WITH CHECK: USING filters rows going out (SELECT, UPDATE, DELETE). WITH CHECK filters rows coming in (INSERT, UPDATE). Both need to be present — or you have a half-locked door.
After writing policies, verify they work in the Supabase SQL Editor:
-- Simulate being user A
SET request.jwt.claims = '{"sub":"user-a-uuid"}';
-- Try to read user B's data — should return 0 rows
SELECT * FROM tasks
WHERE user_id = 'user-b-uuid';
-- ✓ If 0 rows: RLS is working
-- ✗ If rows appear: policy is broken
| Test Case | Expected Result | Pass Criteria |
|---|---|---|
| Same-user query | Returns user's own rows | count > 0 |
| Cross-user query | Returns zero rows | count == 0 always |
| Unauthenticated query | Request blocked | 401 or empty result |
| Admin / service role | Broadened access (intentional) | Only via server-side, never client |
Key metric: Cross-user queries must always return 0 rows.
// Snippet A: Missing input validation
async function createTask(title: string, description: string) {
await supabase.from('tasks').insert({
title: title, // no length check, no sanitization
description: description,
user_id: user.id
});
}
// Snippet B: Client-side-only auth check
export default function Dashboard() {
const [user, setUser] = useState(null);
useEffect(() => {
supabase.auth.getUser().then(({ data }) => {
if (!data.user) window.location.href = '/login';
else setUser(data.user);
});
}, []);
// Dashboard content renders here — no server-side verification
}
// Snippet C: No RLS enabled
// Table 'tasks' has RLS disabled
// Anyone with the Supabase anon key can query all rows
These three are the top vulnerabilities in AI-generated full-stack code:
Run this checklist every time AI generates a form, a protected route, or a database insert.
# CLAUDE.md
## Data Model
- Table: tasks (id, title, description, user_id, created_at)
- RLS: users can only CRUD their own tasks
- Auth: Supabase email auth required for all pages
## Rules
- All inputs validated: title max 100 chars, description max 500 chars
- Server-side auth check on all API routes
When the data model, auth requirements, and RLS rules are in CLAUDE.md, the AI generates correct Supabase queries on the first try. No prompt iteration, no back-and-forth.
Key insight: A chat prompt is ephemeral. A CLAUDE.md is re-read at the start of every session — the 10th query is as accurate as the 1st.
# install & initialize
$ npx openspec init
# write a proposal
$ openspec propose
title: Add task filtering
why: Users need to find tasks by status
what:
ADDED FilterBar component
MODIFIED TaskList to accept filter prop
checklist:
[ ] FilterBar renders status options
[ ] TaskList filters by selected status
[ ] Default: show all tasks
# implement from the checklist
$ openspec apply
Free, no API keys. Works with Claude Code, Cursor, Copilot, and 20+ tools.
Same principle at every scale: agree on what before writing how.
“The spec is the prompt — it just persists.”
The antidote to the vibe coding hangover: write the spec in the planning phase, and it does the security work in the coding phase.