F08 · Session 8 · Chapter 1
Who are you?
Before the platform can apply the matrix, it has to know who is asking. This chapter covers sign-in with Supabase Auth, what a session is and how long it lasts, two-step sign-in, and how sign-in works on a shared tablet on the line.
25 min3 chapters≈ 85 minutes2 exercises12-question assessment · 80% passes
By the end of this chapter you can
- Tell authentication (who you are) from authorisation (what you may do).
- Explain what a session is, where it is kept and why it expires.
- Choose sign-in methods for each role, including two-step sign-in, free options first.
- Set up sign-in for shared tablets without shared logins.
Two different questions
Authentication answers who are you? Authorisation answers what may you do? They are often blurred, and the blur causes real flaws: a platform that lets anyone who is signed in open any page has authentication and no authorisation. OWASP's guidance treats them as separate controls for this reason [1] [2].
In your platform, Supabase Auth handles authentication: it checks the password or link, and issues a session [3]. Everything in this element's chapter 2 is authorisation: the route guard and server checks that apply the matrix, on top of the row-level security from Session 7.
What a session is
After a person signs in, the platform must remember them without asking for the password on every tap. Supabase does this with a pair of tokens: a short-lived access token, a signed statement of who the person is (by default it expires after one hour), and a longer-lived refresh token used to get a new access token quietly while the person keeps working [4]. In a Next.js app the tokens are kept in cookies, so the server can read them on every request [5].
Short tokens limit the damage if one is copied: it stops working within the hour. When the access admin disables a person, their next refresh fails, and the session ends.
Knowledge check
A person signs in successfully. What may they open?
Choosing sign-in for each role
Supabase Auth offers several sign-in methods; pick per role, free options first [3] [7]:
| Method | Good for | Cost |
|---|---|---|
| Email and password, with two-step codes from an authenticator app (TOTP) | Supervisors, managers, maintenance, access admin | Included in Supabase's free tier |
| Email link (magic link) | Occasional users with a company email | Included; needs email sending set up |
| Company single sign-on (SAML) | Plants whose IT already runs one identity provider | A paid Supabase add-on: check supabase.com/pricing before you choose it |
| Personal login on a shared line tablet | Operators | Included; see below |
NIST's digital identity guidelines favour long passwords checked against lists of known breached passwords, no forced periodic changes, and a second factor for anything sensitive [8]. In this course: two-step sign-in is required for every role that can approve, change access or see the whole site, and the access admin role cannot sign in without it.
Shared tablets on the line
Operators often share one tablet per line. The temptation is a shared login such as line2. Never do it: every record must say which person made it, and the matrix's scope and separation-of-duties rules depend on knowing who that is. Instead, each operator signs in personally, quickly, and is signed out at the end of the shift.
- Make sign-in fast: large fields at 16 px, a remembered email per device where your policy allows, and a clear error in plain words.
- Sign out on idle and at shift change, so the next person cannot act as the last.
- Keep the session on the tablet only as long as the shift; a lost tablet should hold nothing that works tomorrow.
Knowledge check
A line lead asks for one shared login per line tablet to save time. What is the right answer?
References
- OWASP Authentication Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Authorization Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- Supabase Docs: Auth. https://supabase.com/docs/guides/auth
- Supabase Docs: User sessions. https://supabase.com/docs/guides/auth/sessions
- Supabase Docs: Setting up server-side Auth for Next.js. https://supabase.com/docs/guides/auth/server-side/nextjs
- MDN Web Docs: Set-Cookie (HttpOnly, Secure, SameSite). https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
- Supabase Docs: Multi-Factor Authentication. https://supabase.com/docs/guides/auth/auth-mfa
- NIST SP 800-63-4, Digital Identity Guidelines. https://pages.nist.gov/800-63-4/
F08 · Chapter 2 · Default deny
Closed until the matrix opens it
Most platforms start open and close routes one by one, and the one they forget is the one that leaks. This chapter turns that round: a route guard generated from the matrix refuses every route the matrix does not open, and a second check next to the data catches anything the guard misses.
30 min2 public routes1 generated route table3 locks≈ 35 minutes hands-on
By the end of this chapter you can
- Explain why routes must be closed by default rather than closed one by one.
- Generate a route table and a decision function from the matrix.
- Wire the route guard into Next.js and add server checks next to the data.
Open by default is how platforms leak
A common pattern is to check sign-in only on the pages someone remembered to protect. Every new page is open until someone remembers. With AI agents adding pages quickly, that is a leak waiting to happen. OWASP's authorization guidance says the opposite: deny by default, and allow only what is explicitly permitted [1].
In this platform the rule is simple: only the routes listed as public are open to everyone (the home page and sign-in). Every other route is closed unless the matrix's screens section opens it to the person's role. A new page that nobody added to the matrix is refused, for everyone, until it is.
Generated, not written
The route table and the decision are generated from access/matrix.json, the same file that generated the database rules in Session 7. Here is what the generator writes:
// lib/access/routes.ts — generated from access/matrix.json. Do not edit by hand.
export const PUBLIC = ["/", "/sign-in", "/auth/callback"];
export const SCREENS: Record<string, string[]> = {
"/operator": ["operator"],
"/review": ["supervisor"],
"/dashboard": ["supervisor", "manager"],
"/work": ["supervisor", "maintenance"],
"/people": ["access_admin"],
};
export type Verdict = "allow" | "sign-in" | "forbidden";
export function decide(path: string, roles: string[] | null): Verdict {
if (PUBLIC.includes(path)) return "allow";
if (!roles) return "sign-in"; // no valid session
const screen = Object.keys(SCREENS).find((s) => path === s || path.startsWith(s + "/"));
if (!screen) return "forbidden"; // not in the matrix: closed
return SCREENS[screen].some((r) => roles.includes(r)) ? "allow" : "forbidden";
}Wiring it into Next.js
Next.js runs a file called proxy.ts (named middleware.ts before Next.js 16) before any page or route, which makes it the natural place for the route guard [2]. It reads the Supabase session from the cookies, takes the person's roles from the session token, and asks decide(). Supabase's guide for Next.js shows how to read and refresh the session there [3]. A Supabase custom access token hook can copy each person's roles from their assignments into the token at every refresh, so the guard needs no database call; Next.js recommends exactly that for checks in the proxy, which runs on every request [6] [5].
// proxy.ts — the route guard. Runs before every page and route.
import { NextResponse, type NextRequest } from "next/server";
import { decide } from "@/lib/access/routes";
import { currentRoles } from "@/lib/access/session"; // roles from the session token; null if none or expired
export async function proxy(request: NextRequest) {
const roles = await currentRoles(request);
const verdict = decide(request.nextUrl.pathname, roles);
if (verdict === "allow") return NextResponse.next();
if (verdict === "sign-in") return NextResponse.redirect(new URL("/sign-in", request.url));
return new NextResponse("Not allowed", { status: 403 });
}
// Run on everything except the framework's own static files.
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };Why one lock is not enough
In March 2025 a flaw in Next.js (CVE-2025-29927) let a crafted request header skip middleware entirely in some versions, so any platform whose only check was in middleware was open [4]. It was fixed quickly, but the lesson stands: the route guard is the first lock, not the only one. Next.js's own authentication guide recommends checking authorisation again close to the data [5].
- Route guard (proxy.ts): closed unless the matrix opens the route.
- Server check: every server action and API route calls one generated helper, for example
requireRole("supervisor"), which checks the person's current assignment in the database before anything is read or written. - Row-level security (Session 7): even a request that slips past both gets only the rows its person's scope allows.
Knowledge check
An agent adds a new page, /reports, and forgets the matrix. Who can open it?
You need: Your repository with access/matrix.json, your AI coding agent, Supabase Auth set up from Session 1
Your agent writes the code; you check that a route not in the matrix is closed and that each role reaches only its screens.
Outcome: A route guard and server checks generated from the matrix: only public routes are open, every other route is closed until the matrix opens it to a role.
Knowledge check
Why does every server action also call requireRole, when proxy.ts already checks the route?
References
- OWASP Authorization Cheat Sheet (deny by default). https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- Next.js Docs: proxy.js file convention. https://nextjs.org/docs/app/api-reference/file-conventions/proxy
- Supabase Docs: Setting up server-side Auth for Next.js. https://supabase.com/docs/guides/auth/server-side/nextjs
- NIST National Vulnerability Database: CVE-2025-29927 (Next.js middleware authorization bypass). https://nvd.nist.gov/vuln/detail/CVE-2025-29927
- Next.js Docs: Authentication guide (optimistic checks in Proxy, Data Access Layer). https://nextjs.org/docs/app/guides/authentication
- Supabase Docs: Custom Access Token Hook. https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook
F08 · Chapter 3 · Tests and operations
Proving the door is shut
Default deny is a claim until a test proves it. This chapter writes the tests that matter (signed out, wrong role, expired session, route not in the matrix) and the routines that keep sign-in safe once real people use it: leavers, lost tablets and lockouts.
15 min4 kinds of test1 route completeness checksame-day leaver rule
By the end of this chapter you can
- Write tests for signed-out, wrong-role, expired-session and unlisted-route requests.
- Add a completeness check: every page and API route is either public or in the matrix.
- Run sign-in day to day: leavers, lost devices, lockouts and self-service resets.
Four tests that prove default deny
| Test | Request | Expected |
|---|---|---|
| Signed out | Open /review with no session | Redirect to /sign-in |
| Wrong role | Open /review signed in as an operator | 403 Not allowed |
| Expired session | Open /operator with an expired or tampered token | Redirect to /sign-in |
| Not in the matrix | Open a route that exists but is not listed | 403 for every role |
Generate the first two kinds from the matrix, like the database tests in Session 7: for every screen, one role that must get in and one that must not. The expired-session test proves that an old cookie found on a shared tablet is worthless. OWASP's session management guidance lists expiry and invalidation as core controls [1].
// test/routes.test.mjs — generated cases plus the edge cases.
import assert from "node:assert/strict";
import { decide, SCREENS, PUBLIC } from "../lib/access/routes.ts";
for (const route of PUBLIC) assert.equal(decide(route, null), "allow", `${route} is public`);
for (const [route, roles] of Object.entries(SCREENS)) {
assert.equal(decide(route, null), "sign-in", `${route}: signed out goes to sign-in`);
for (const role of roles) assert.equal(decide(route, [role]), "allow", `${route}: ${role} allowed`);
assert.equal(decide(route, ["nobody"]), "forbidden", `${route}: unknown role refused`);
}
assert.equal(decide("/reports", ["manager"]), "forbidden", "a route not in the matrix is closed");
console.log("route guard: default deny holds");These test the decision. Add a few end-to-end tests that send real requests to the running app (signed out, as an operator, with an expired cookie) so the wiring in proxy.ts and the server checks is proven too.
Every route accounted for
Session 6's completeness test covered tables. Its twin covers routes: list every page and API route in the app folder and fail if any is neither public nor in the matrix. A new page added without a matrix entry fails CI on the same pull request, before anyone can open it.
Knowledge check
Why test with an expired session as well as a signed-out one?
Sign-in day to day
Once real people use the platform, sign-in is mostly routine. Write the routines down in the runbook (Session 18) so they happen the same way every time.
- Leavers: the access admin removes the person's assignments and disables their login the same day. Their next token refresh fails, and the server check refuses them at once because it reads current assignments.
- Lost or stolen tablet: sign that device's sessions out from the access admin screen, and require sign-in again.
- Forgotten password: "Forgot your password?" on the sign-in page, so people help themselves; the access admin is the fallback.
- Lockouts and guessing: Supabase Auth limits repeated sign-in attempts [2]; your sign-in page should say plainly when to try again and who to ask.
- Quarterly review: each area owner reviews the generated access sheet from Session 6 against who actually works there.
You need: Your repository with the generated route guard, your AI coding agent, GitHub
Your agent writes the tests; you check that each of the four kinds is there and that a refused case really checks for a refusal.
Outcome: CI proves that signed-out, wrong-role, expired and unlisted requests are refused, and that every route is accounted for in the matrix.
Knowledge check
A supervisor leaves the company on Friday. What must happen that day?
References
- OWASP Session Management Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Supabase Docs: Auth rate limits. https://supabase.com/docs/guides/auth/rate-limits
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
Sign-in That Denies by Default: Every Route Closed Until the Matrix Opens It
Element F08 complete · Learner
Your LMS records this completion. For the CivOps Foundation certificate, finish the Foundation Course at https://civops.io/learn.