Chapter 1 · Session 2B
Access rules on every table
The browser on an operator's phone talks to your database directly. What keeps one person from reading everything is not the screen; it is a rule inside the database that runs on every query.
30 minPostgres row-level securityDefault denySupabase Security Advisor
By the end of this chapter you can
- Explain why the database, not the screen, is where access is enforced.
- Turn on row-level security for every table and write policies from the role matrix.
- Prove with SQL that a stranger reads nothing and a member reads only their site.
Why the database is the lock
A Supabase app ships a publishable key (called the anon key on older projects) inside the web page. That is by design: the key only identifies your project. Anyone can copy it from the browser and call your database's API directly, without your screens [1]. So hiding a button protects nothing. What protects the data is row-level security (RLS): a rule Postgres applies to every query, for the person making it [2][3].
Default deny, built into Postgres
Postgres has default deny built in. When RLS is turned on for a table and no policy grants access, nobody but the table's owner sees a single row [2]. Every grant is then a policy: a condition that must be true for a row to be read, added, changed or removed. If no policy grants it, the action is refused.
From the matrix to policies
Session 6 builds the full Role and Exposure Matrix. Today you write the first rows by hand for the stamping plant's tables: who may read and add inspections and holds, per site. Each cell that grants something becomes one policy. Each empty cell becomes nothing at all, and is therefore refused.
-- Who belongs to which site, with which role. People read only their own rows here.
create table public.memberships (
user_id uuid not null references auth.users(id) on delete cascade,
site_id bigint not null references public.sites(id),
role text not null check (role in ('operator', 'quality_lead', 'manager')),
primary key (user_id, site_id)
);
alter table public.memberships enable row level security;
create policy "read own memberships" on public.memberships
for select to authenticated using (user_id = (select auth.uid()));
-- Inspections: members of the site read them; operators and quality leads add them.
alter table public.inspections enable row level security;
create policy "site members read inspections" on public.inspections
for select to authenticated
using (site_id in (select site_id from public.memberships where user_id = (select auth.uid())));
create policy "operators and leads add inspections" on public.inspections
for insert to authenticated
with check (site_id in (select site_id from public.memberships
where user_id = (select auth.uid()) and role in ('operator', 'quality_lead')));
-- No update or delete policy: an inspection, once recorded, is corrected by a new row.Two details from Supabase's guidance matter here: name the role the policy applies to (to authenticated), so it never runs for strangers, and wrap auth.uid() in a select so Postgres works it out once per query rather than once per row [3].
Prove it in the SQL editor
You do not have to trust a policy; you can test it. In the Supabase SQL editor, these queries run as a stranger and as a test user, inside a transaction that is rolled back so nothing changes. The first query lists every table that still has RLS off; the answer must be empty.
-- 1. Tables in the public schema with row-level security OFF. Must return no rows.
select c.relname as table_without_rls
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity;
-- 2. As a stranger (the publishable key, nobody signed in): must return 0.
begin;
set local role anon;
select count(*) from public.inspections;
rollback;
-- 3. As the test operator you invited (Authentication, Users): only their site's rows.
begin;
set local role authenticated;
select set_config('request.jwt.claims',
json_build_object('sub', (select id from auth.users where email = 'operator.test@example.com'), 'role', 'authenticated')::text, true);
select site_id, count(*) from public.inspections group by site_id;
rollback;You need: Your Supabase project (free plan), the SQL editor, and an AI coding agent working in your repository
Do this on the project you created in Session 1, before any real data is in it. If your tables from Session 3 do not exist yet, ask the agent to create the sites, memberships and inspections tables shown above as a migration first.
Outcome: Every table has RLS on; a stranger reads nothing; a member reads only their own site; the Security Advisor is clean.
Knowledge check
The publishable key is visible in the browser. Why is that safe?
Knowledge check
A table has row-level security turned on and no policies. Who can read it through the API?
Knowledge check
Where may the secret (service role) key be used?
References
- Supabase Docs: Understanding API keys. https://supabase.com/docs/guides/api/api-keys
- PostgreSQL Documentation: Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- Supabase Docs: Row Level Security. https://supabase.com/docs/guides/database/postgres/row-level-security
- Supabase Docs: Database Advisors. https://supabase.com/docs/guides/database/database-advisors
Chapter 2 · The front door
Sign-in that denies by default
Row-level security protects the data. The sign-in guard protects the pages. Both follow one rule: nothing is open until someone opens it on purpose.
25 minInvite-onlyAllow-list of public pagesTwo-step sign-in for leads
By the end of this chapter you can
- Close public sign-up so only invited people have accounts.
- Write the guard as an allow-list: a short list of public pages, everything else needs a session.
- Set session lifetime and two-step sign-in in line with NIST and OWASP guidance.
Who can have an account
A plant platform is not a public website. Nobody should be able to create an account by visiting the sign-in page. In the Supabase dashboard, under Authentication, turn off Allow new users to sign up; from then on people get accounts only when you invite them [1]. This one switch closes a whole class of attack: there is no account for a stranger to make.
Deny by default, at the front door
Every request to your app passes a guard before any page is built. In a Next.js app this is a single file at the root of the project (Next.js 16 names it proxy.ts; earlier versions called it middleware.ts) [2]. The guard asks four questions in order, and any 'no' stops the request.
The key design choice is the list. An allow-list names the few pages anyone may see (the home page, sign-in, the link people click in an invitation) and closes the rest. A block-list names pages to protect and leaves the rest open. The OWASP Authorization Cheat Sheet recommends denying by default for exactly the reason below [3].
// proxy.ts (outline): every request comes here first.
// The few public pages are listed; everything else needs a signed-in user.
const PUBLIC = new Set(["/", "/sign-in", "/auth/callback"]);
export async function proxy(request) {
if (PUBLIC.has(request.nextUrl.pathname)) return next(); // 1. public page
const user = await currentUser(request); // 2. signed in? (Supabase checks the token)
if (!user) return redirectTo("/sign-in", request); // no: go and sign in
if (!allowed(user.role, request.nextUrl.pathname)) // 3-4. fresh session, role allowed by the matrix
return new Response("Not allowed", { status: 403 });
return next();
}Sessions and two-step sign-in
NIST's digital identity guidelines and OWASP both say the same things about sessions: they expire, they end at sign-out, and the people who can change important data prove who they are with a second factor [5][6]. For a plant platform, that means:
| Setting | Where | Plant default |
|---|---|---|
| Public sign-up | Supabase, Authentication | Off: invite only |
| Session ends after inactivity | Free way: your app signs people out at the end of the shift. Supabase Pro adds inactivity timeouts (Authentication, Sessions) | A shift plus an hour (about 9 hours) for operators |
| Two-step sign-in | Supabase Auth MFA (authenticator app) | Required for quality leads, managers and anyone who approves |
| Sign-out | Your app | Ends the session on the server, not just in the browser |
| Shared tablets at the press | Your app | Short sessions and a large sign-out button; never a shared login |
Supabase supports two-step sign-in with an authenticator app (time-based one-time passwords) at no extra cost on every plan [7]. Turn it on for the roles that can hold or release a lot. Operators on shared tablets get short sessions instead, because a code on a phone is impractical in gloves.
Knowledge check
Why is an allow-list of public pages safer than a block-list of private ones?
Knowledge check
A page somehow slips past the sign-in guard. What still protects the data?
References
- Supabase Docs: Auth (general configuration, sign-ups). https://supabase.com/docs/guides/auth
- Next.js Docs: proxy.ts (formerly middleware). https://nextjs.org/docs/app/api-reference/file-conventions/proxy
- OWASP Authorization Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- Supabase Docs: Server-side Auth for Next.js. https://supabase.com/docs/guides/auth/server-side/nextjs
- NIST SP 800-63B-4: Digital Identity Guidelines, Authentication and Authenticator Management. https://csrc.nist.gov/pubs/sp/800/63/b/4/final
- OWASP Session Management Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Supabase Docs: Multi-Factor Authentication. https://supabase.com/docs/guides/auth/auth-mfa
Chapter 3 · Tests are the referee
Prove it: tests for the OWASP Top 10
A security rule that is not tested is a hope. Each of the OWASP Top 10 risks becomes at least one automated test that runs on every pull request, so no change, from you or an agent, can quietly open a door.
25 minOWASP Top 10:202510 risks, 10+ testsRuns in CI on every change
By the end of this chapter you can
- Name the OWASP Top 10:2025 categories and what each looks like in a plant app.
- Write tests that prove access is refused to strangers and wrong roles.
- Run the security tests in CI so a failing one blocks the merge.
The ten risks
The OWASP Top 10 is the most widely used list of the most serious web application security risks, revised from real-world data; the 2025 edition is the current one [1]. It is a starting point, not a complete standard: the OWASP Application Security Verification Standard (ASVS) is the detailed checklist when you need one, and the Web Security Testing Guide shows how to test each item [2][3].
| Risk | In a plant app | The test |
|---|---|---|
| A01 Broken access control | An operator opens another site's holds by changing a number in the address | Stranger and wrong-role requests are refused, page and API |
| A02 Security misconfiguration | Sign-up left open; a table without RLS | Sign-up is refused; the no-RLS query returns nothing; security headers are present |
| A03 Software supply chain failures | A package with a known flaw, or a changed one | Lockfile committed; npm audit and Dependabot alerts checked [4][5] |
| A04 Cryptographic failures | A key in the browser bundle; plain HTTP | No secret key in the built pages; HTTP redirects to HTTPS |
| A05 Injection | A lot number with a quote in it breaks a query | Inputs with quotes and script tags are stored and shown as text |
| A06 Insecure design | The system can release a hold by itself | The intent's safety rule has its own test |
| A07 Authentication failures | An expired session still works | Expired or signed-out sessions are refused |
| A08 Software or data integrity failures | A change reaches main without review | main is protected; CI must pass; records are append-only |
| A09 Security logging and alerting failures | Refused requests leave no trace | A refused request writes an audit row |
| A10 Mishandling of exceptional conditions | An error page shows a stack trace or fails open | A forced error shows a plain message and denies access |
The first tests
Your AI agent writes these in a pull request; you read them before merging. They need no special tools: Node's built-in test runner and fetch are enough. The addresses and keys come from environment variables that you type into GitHub's Actions secrets yourself, never into a chat or a file.
// tests/security.test.mjs (run with: node --test)
import test from "node:test";
import assert from "node:assert/strict";
const BASE = process.env.BASE_URL; // the preview address under test
const API = process.env.SUPABASE_URL, KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
test("A01: a stranger asking for a private page is sent to sign in", async () => {
const r = await fetch(BASE + "/holds", { redirect: "manual" });
assert.ok([302, 303, 307].includes(r.status));
assert.match(r.headers.get("location") || "", /\/sign-in/);
});
test("A01: the publishable key alone reads no inspections", async () => {
const r = await fetch(API + "/rest/v1/inspections?select=id", { headers: { apikey: KEY } });
assert.deepEqual(await r.json(), []);
});
test("A02: public sign-up is closed", async () => {
const r = await fetch(API + "/auth/v1/signup", { method: "POST", headers: { apikey: KEY, "content-type": "application/json" },
body: JSON.stringify({ email: "stranger@example.com", password: "a-long-test-password-123" }) });
assert.ok(r.status >= 400, "a stranger could create an account");
});
test("A02: security headers are set", async () => {
const r = await fetch(BASE + "/");
assert.ok(r.headers.get("content-security-policy"), "no Content-Security-Policy");
assert.equal(r.headers.get("x-content-type-options"), "nosniff");
});The header test checks for a Content Security Policy, which tells the browser which scripts may run and so blunts cross-site scripting [6]. Add tests for the other categories one pull request at a time; the table above gives the test for each.
Make the tests the referee
The tests only protect you if a failing one stops the merge. Session 1 protected main so that pull requests must pass CI; add the security tests to that CI run. From then on, a change that opens a door, from a person or an agent, turns the pull request red and cannot merge.
You need: Your repository, an AI coding agent, GitHub Actions (free for the minutes this needs)
Do this after chapter 1's exercise, so the tables already have RLS on.
Outcome: Five security tests run on every pull request, one of them proven to fail when its rule is broken, and main accepts only green changes.
Knowledge check
Which OWASP Top 10:2025 category covers an operator reading another site's holds by changing the address?
Knowledge check
How do you know a security test actually tests something?
References
- OWASP Top 10:2025. https://owasp.org/Top10/2025/
- OWASP Application Security Verification Standard (ASVS). https://owasp.org/www-project-application-security-verification-standard/
- OWASP Web Security Testing Guide. https://owasp.org/www-project-web-security-testing-guide/
- npm Docs: npm audit. https://docs.npmjs.com/cli/commands/npm-audit
- GitHub Docs: Dependabot. https://docs.github.com/en/code-security/dependabot
- MDN Web Docs: Content-Security-Policy. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
Chapter 4 · 12 questions · 80% passes
Final assessment
Twelve questions across the element. Score 80% (10 of 12) to pass. Your LMS records your score and each answer; you can review the chapters and try again.
15 min12 questions≈ 15 minutesRetake allowed
Your result
CivOps AI Academy
Security From Day One: Access Rules, Default-Deny Sign-In and OWASP Tests
Element F02B complete · Learner
Your LMS records this completion. For the CivOps Foundation certificate, finish the Foundation Course at https://civops.io/learn.