TECHIN 510 — Spring 2026

Full-Stack Applications

Week 6: Architecture, Auth, CRUD & Security
University of Washington • Global Innovation Exchange
01 / 37
What You Will Learn

Learning Objectives

1
Explain why RLS is necessary Understand data exposure risks without Row Level Security
2
Implement authentication flows Supabase Auth: sign-up, sign-in, and session management
3
Design a relational data model Work backward from user-facing screens and JTBD requirements
4
Build a validated form Client-side and server-side input validation to prevent malformed data
02 / 37
What You Will Learn

Learning Objectives (continued)

5
Write an RLS policy in Supabase Verify that users cannot access each other’s data
6
Identify AI-generated security vulnerabilities Explain why they occur and how to catch them
7
Use a spec as AI context PRD / CLAUDE.md drives architecture-aligned, security-aware code generation

Today: lecture + demo (55 min) then hands-on lab (105 min)

03 / 37

Application
Architecture

Three layers, three security boundaries
04 / 37
Systems Thinking

Security Stack Across the Quarter

W1 — No secrets needed (free APIs)
W3 — Format contracts (CSV specs, validation)
W4 — Asserts (DataFrame consistency checks)
W5 — Environment variables + error boundaries
W6Auth + RLS + defense in depth
W7 — Agent guardrails (tool constraints, refusal policies)
W8 — Automated tests + CI pipelines
W9 — Agent evaluation sets + demo stability
05 / 37
The Security Demo

Every Row, Every User

“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.

06 / 37
Architecture

Three-Layer Architecture

BROWSER /login (public) /dashboard (protected) /settings (protected) HTTP Request NEXT.JS SERVER Pages & Layout (React Server Components) API Routes /api/tasks · /api/profile SQL (with RLS) SUPABASE (POSTGRESQL) auth.users (managed) tasks (with RLS) profiles (with RLS)

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.

07 / 37
Data Modeling

User-First Data Modeling

Dashboard Card

What the user sees:

My Tasks

Title • Status • Created

Buy groceries • pending • Mar 4

Review PR • done • Mar 3

SQL Schema
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()
);
08 / 37
Design Principle

Screen → Spec → Schema → RLS

“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.

09 / 37

Auth &
Protected Routes

Who is this person?
10 / 37
Authentication

Sign-Up vs. Sign-In

Sign-Up (creates user)
const { data, error } =
  await supabase.auth.signUp({
    email: 'alice@example.com',
    password: 'securepassword123'
  });
Sign-In (creates session)
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.

11 / 37
Authentication

Session Management

“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.

12 / 37
Protected Routes

The Bug: Flash of Content

// 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.

13 / 37
Protected Routes

The Fix: Loading State First

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.

14 / 37
CRUD

Inserting with user_id

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.

15 / 37
Data Integrity

Form Validation with Zod

Define the Schema

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),
});

Server-Side Parse

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);
Client validation is UX. Server validation is security. You need both. Zod lets you share one schema across client and server.
16 / 37
Architecture

Data Contracts at Every Boundary

BoundaryContract MechanismWhat 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.

17 / 37
Check Your Understanding

The Auth Chain

1
Sign-up creates the user record
2
Sign-in creates the session (JWT)
3
Session guards the route
4
user_id links every row to its owner → enables RLS

This chain is the backbone of everything we build today.

18 / 37

UX for AI
Applications

Context engineering for humans
19 / 37
UX Principle

Context Engineering for Users

“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.

20 / 37
UX Patterns

Three Patterns That Matter

1
Loading states are context signals A spinner or skeleton screen is the system telling the user “I am working, not broken.” In Next.js, the loading.tsx file does exactly this.
2
Progressive disclosure in AI apps Show results as they become available. Users can begin processing partial information. Perplexity’s source count, Copilot’s grayed-out suggestions — the system narrating its own reasoning.
3
Flash-of-content is a context failure When protected content briefly appears, users doubt whether the system is actually protecting their data. The loading state is not a UX nicety — it is a security layer.
21 / 37
Principle

Context Engineering for Humans

“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.

22 / 37

Verification
& Security

Row Level Security and defense in depth
23 / 37
Row Level Security

RLS: Enable → Deny All

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.

24 / 37
Row Level Security

SELECT Policy

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.

25 / 37
Defense in Depth

Without RLS vs. With RLS

Without RLS
Your code:
SELECT * FROM tasks
  WHERE user_id = current_user

Bug in code:
SELECT * FROM tasks
  ← forgets WHERE clause

Result: ALL data exposed
With RLS
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.

26 / 37
Agentic AI

RLS Is Non-Negotiable for Agents

The Bug

Agent uses service_role key.
Query: SELECT * FROM todos
Result: Returns ALL users' data.

The Fix

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.

27 / 37
Row Level Security

INSERT & UPDATE Policies

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.

28 / 37
Security Verification

Testing Your RLS

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 every policy. A missing RLS policy means any authenticated user can read/modify any row. This is the #1 Supabase security mistake.
Test CaseExpected ResultPass 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.

29 / 37
PRIMM: Predict

Find the Vulnerability

// 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
30 / 37
PRIMM: Investigate

Vulnerability Debrief

A
Missing input validation No length limit, no sanitization. An XSS payload goes straight into the database and renders in another user’s browser. Fix: validate on the server before the insert.
B
Client-side-only auth check The useEffect runs in the browser. JavaScript can be disabled. A direct API call bypasses it entirely. The redirect is a UX affordance, not a security control.
C
No RLS enabled The Supabase anon key is public — it’s in your client-side bundle. Without RLS, that key is a skeleton key to your entire table. This is the default state of a new Supabase project.
31 / 37
Security

AI Security Checklist

These three are the top vulnerabilities in AI-generated full-stack code:

1
Missing input validation Does the form check length, type, and special characters on the server?
2
Client-side-only auth Is there a server-side auth check, or only a browser redirect?
3
No Row Level Security Is RLS enabled on every table with user-specific data?

Run this checklist every time AI generates a form, a protected route, or a database insert.

32 / 37

Spec as Context

The planning artifact that codes for you
33 / 37
Spec-Driven AI

CLAUDE.md as Context

# 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.

34 / 37
SPEC-DRIVEN AI

OpenSpec: Specs Before Code

1
Propose Write what changes and why before touching code — ADDED / MODIFIED / REMOVED delta markers
2
Apply Implement tasks from the approved spec checklist
3
Archive Completed specs become project history
# 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.

35 / 37
SPEC-DRIVEN AI

Your Spec Stack So Far

W3
Format Contract Data boundaries — agree on shape before processing
W4
CTOC Chart-level functional spec — what the visualization must show
W6
CLAUDE.md / PRD Persistent project context — re-read every session
W6
OpenSpec Enforced propose → apply → archive workflow

Same principle at every scale: agree on what before writing how.

36 / 37
Week 6 Takeaway

The Spec Is the Prompt

“The spec is the prompt — it just persists.”

40% Planning
20% Coding
40% Testing

The antidote to the vibe coding hangover: write the spec in the planning phase, and it does the security work in the coding phase.

37 / 37