Skip to the lesson
CivOps AI Academy · F02BSecurity From Day One: Access Rules, Default-Deny Sign-In and OWASP Tests
0%

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

The path of a requestA phone browser sends the publishable key and the user's token to the Supabase API, which passes the user to Postgres, where row-level security returns only that user's rows. Server code with the secret key bypasses row-level security and must stay on the server. Phone browserpublishable key+ user's sign-in tokenSupabase APIchecks the token,passes the user onPostgresrow-level securityruns on every queryfor this userRowsonly theirsYour server codesecret key, server onlyThe secret key skips row-level security.It never reaches a browser, a chat or a file.
The path of every request. The browser carries the publishable key and the user's sign-in token; Postgres applies row-level security for that user. The secret key (service role) skips RLS, so it lives only in server code.

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.

Row-level security, three waysThree panels of eight table rows: with row-level security off all rows are readable; on with no policy, none; on with a policy, only the rows for the person's own site. RLS offAnyone with the publishable keyreads every rowRLS on, no policyDefault deny:nobody reads anythingRLS on + policyEach person readstheir own site's rows
Three states of the same table. Off, every row is exposed to anyone holding the publishable key. On with no policy, nothing is readable. On with a policy, each person reads exactly what the matrix grants.

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.

From the role matrix to policiesA grid of three roles against three tables, with read and add permissions, and an arrow to the list of row-level security policies generated from it. ROLE MATRIXinspectionsholdsmembershipsOperatorread, addreadownQuality leadread, addread, addownManagerreadreadread, addGENERATED POLICIESinspections: select for site membersinspections: insert for operator, quality leadholds: select for site membersholds: insert for quality lead onlyno update or delete policy anywherememberships: each reads their ownAnything the matrix does not grant has no policy, so it is refused.
The matrix becomes policies. No role has an update or delete policy on inspections or holds: results are append-only, and corrections are new rows.
Postgres: the first policies, written from the matrix
-- 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.

Supabase SQL editor: three checks
-- 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;
Exercise · Lock every table30 minutes

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

  1. Supabase Docs: Understanding API keys. https://supabase.com/docs/guides/api/api-keys
  2. PostgreSQL Documentation: Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
  3. Supabase Docs: Row Level Security. https://supabase.com/docs/guides/database/postgres/row-level-security
  4. 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 sign-in guardA request passes four checks in turn: is it a public page, is the person signed in, is the session fresh, does their role allow it. A failed check sends them to sign in or returns 403. RequestPublic page?on the short listSigned in?valid sessionFresh?not expiredRole allows?per the matrixServepublic page: served at onceSend to sign-inno session, or it expired403 Not allowedsigned in, wrong roleAny 'no' stops the request. A new page is closed until someone lists it.
The guard. Public pages are a short, written list. Everything else needs a valid, fresh session, and the person's role must allow the page.

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

Allow-list or block-listTwo panels. With an allow-list a newly added page is closed until listed; with a block-list a newly added page with a slightly different name is open to anyone. Default deny (allow-list)Public: /, /sign-inEverything else needs a sessionAgent adds /admin/export/admin/export is closedDefault allow (block-list)Blocked: /admin, /settingsEverything else is openAgent adds /admin-export/admin-export is open to anyone
Why allow-lists win. An AI agent adds a page with a slightly different name. With an allow-list it is closed until someone lists it; with a block-list it is open to the internet.
The guard as an allow-list (your agent writes the full file from Supabase's Next.js guide)
// 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:

SettingWherePlant default
Public sign-upSupabase, AuthenticationOff: invite only
Session ends after inactivityFree 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-inSupabase Auth MFA (authenticator app)Required for quality leads, managers and anyone who approves
Sign-outYour appEnds the session on the server, not just in the browser
Shared tablets at the pressYour appShort 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

  1. Supabase Docs: Auth (general configuration, sign-ups). https://supabase.com/docs/guides/auth
  2. Next.js Docs: proxy.ts (formerly middleware). https://nextjs.org/docs/app/api-reference/file-conventions/proxy
  3. OWASP Authorization Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
  4. Supabase Docs: Server-side Auth for Next.js. https://supabase.com/docs/guides/auth/server-side/nextjs
  5. NIST SP 800-63B-4: Digital Identity Guidelines, Authentication and Authenticator Management. https://csrc.nist.gov/pubs/sp/800/63/b/4/final
  6. OWASP Session Management Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
  7. 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].

The OWASP Top 10:2025 as testsTen boxes, one per OWASP Top 10:2025 category, each with a test the platform can run on every change. A01 Broken access controlstranger and wrong role refusedA02 Misconfigurationheaders set, sign-up closedA03 Supply chainlockfile, npm audit, DependabotA04 Cryptographic failuresHTTPS only, no secret in bundleA05 Injectionquotes in input stay textA06 Insecure designthe safety rule has a testA07 Authentication failuresexpired session refusedA08 Integrity failuresprotected main, CI must passA09 Logging and alertingrefusals are loggedA10 Exceptional conditionserrors show no internals
The OWASP Top 10:2025, one test each. A01, broken access control, is first in the list and first in your test suite.
RiskIn a plant appThe test
A01 Broken access controlAn operator opens another site's holds by changing a number in the addressStranger and wrong-role requests are refused, page and API
A02 Security misconfigurationSign-up left open; a table without RLSSign-up is refused; the no-RLS query returns nothing; security headers are present
A03 Software supply chain failuresA package with a known flaw, or a changed oneLockfile committed; npm audit and Dependabot alerts checked [4][5]
A04 Cryptographic failuresA key in the browser bundle; plain HTTPNo secret key in the built pages; HTTP redirects to HTTPS
A05 InjectionA lot number with a quote in it breaks a queryInputs with quotes and script tags are stored and shown as text
A06 Insecure designThe system can release a hold by itselfThe intent's safety rule has its own test
A07 Authentication failuresAn expired session still worksExpired or signed-out sessions are refused
A08 Software or data integrity failuresA change reaches main without reviewmain is protected; CI must pass; records are append-only
A09 Security logging and alerting failuresRefused requests leave no traceA refused request writes an audit row
A10 Mishandling of exceptional conditionsAn error page shows a stack trace or fails openA 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.

Four tests for A01 and A02
// 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.

Security tests in CIA pull request runs four groups of security tests: access, headers and configuration, input and errors, and dependencies. Only if all pass can it merge; otherwise it is blocked. Pull requestyou or an agentAccess testsA01, A07Header and configA02, A04Input and errorsA05, A10DependenciesA03, A08All green?MergedeploysBlockedfix and push
Security tests in CI. Four groups of tests run on every pull request. Any red result blocks the merge until it is fixed.
Exercise · Your first security suite30 minutes

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

  1. OWASP Top 10:2025. https://owasp.org/Top10/2025/
  2. OWASP Application Security Verification Standard (ASVS). https://owasp.org/www-project-application-security-verification-standard/
  3. OWASP Web Security Testing Guide. https://owasp.org/www-project-web-security-testing-guide/
  4. npm Docs: npm audit. https://docs.npmjs.com/cli/commands/npm-audit
  5. GitHub Docs: Dependabot. https://docs.github.com/en/code-security/dependabot
  6. 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

Choose one answer for each question, then submit. You will see the right answer and why for every question.

1. Why can't hiding a button on a screen protect data in a Supabase app?
2. What does Postgres do when row-level security is on and no policy exists for a table?
3. Which SQL check should return no rows after every schema change?
4. Why does a policy say 'to authenticated'?
5. Results in the inspections table are corrected by new rows. Which policies follow?
6. What is the effect of turning off 'Allow new users to sign up'?
7. An agent adds a page called /admin-export. Under an allow-list guard it is:
8. Who should be required to use two-step sign-in on the plant platform?
9. Which OWASP Top 10:2025 category is first in the list?
10. Which test belongs to A07 Authentication failures?
11. Where do the Supabase address and key for the CI tests come from?
12. You find a hole in access control. What is the right order?