Skip to the lesson
CivOps AI Academy · F08Sign-in That Denies by Default: Every Route Closed Until the Matrix Opens It
0%

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

Authentication and authorisationAuthentication answers who you are; the session carries that answer; authorisation, from the matrix, decides what you may do on each request. AuthenticationWho are you?password + two-step codeanswered by Supabase AuthSessiona signed token in a cookiesays who you areexpires and is refreshedAuthorisationWhat may you do?role and scope from the matrixchecked on every requestSigning in proves who you are. It grants nothing by itself: the matrix decides what you may open.
Two questions, one session between them. Supabase Auth answers the first; the matrix from Session 6 answers the second, on every request.

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

A session's lifeOver a shift the access token is refreshed each hour; after sign-out there is no session and every route except sign-in is refused. 06:0008:0010:0012:0014:0016:0018:00tokentokentokentokentokentokentokentokensigns insigns outno session: every route but sign-in refusedAn eight-hour shift: a short-lived access token, refreshed quietly each hour while the person is activeSign out, idle too long or disabled by the access admin: the next refresh fails and the session ends
A session's life over one shift. Short tokens, quietly renewed. When the session ends, every route but sign-in is closed again.

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.

Cookie flags that protect a sessionA session cookie with HttpOnly, Secure and SameSite flags, each explained, plus a short token life. Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/HttpOnlypage scripts cannot read it, so an injected script cannot steal itSecuresent only over HTTPS, never in clear textSameSite=Laxnot sent on most requests started by other sitesShort lifethe access token expires within the hour; a refresh renews it
Cookie flags that protect a session. Check which flags your sign-in library sets. Some, including Supabase's browser helpers, need page scripts to read the cookie, which is why short token life and the platform's Content Security Policy matter too [6].

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

MethodGood forCost
Email and password, with two-step codes from an authenticator app (TOTP)Supervisors, managers, maintenance, access adminIncluded in Supabase's free tier
Email link (magic link)Occasional users with a company emailIncluded; needs email sending set up
Company single sign-on (SAML)Plants whose IT already runs one identity providerA paid Supabase add-on: check supabase.com/pricing before you choose it
Personal login on a shared line tabletOperatorsIncluded; 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.

Shared tablets on the floorA line tablet used by two shifts: each operator signs in personally and is signed out at the shift change. Shift A · operator #2214signed outShift B · operator #3107signed out00:0006:0012:0018:0000:00One tablet on line 2, two people: each signs in personally; the shift change signs the last one outNever a shared login: every record must say which person made it
One tablet, two shifts, two people. Personal sign-in, and sign-out at the shift change, keep every record traceable.
  • 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.
1person per login, always
1 hourdefault access token life in Supabase
2-stepfor anyone who can approve or change access

Knowledge check

A line lead asks for one shared login per line tablet to save time. What is the right answer?

References

  1. OWASP Authentication Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
  2. OWASP Authorization Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
  3. Supabase Docs: Auth. https://supabase.com/docs/guides/auth
  4. Supabase Docs: User sessions. https://supabase.com/docs/guides/auth/sessions
  5. Supabase Docs: Setting up server-side Auth for Next.js. https://supabase.com/docs/guides/auth/server-side/nextjs
  6. MDN Web Docs: Set-Cookie (HttpOnly, Secure, SameSite). https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
  7. Supabase Docs: Multi-Factor Authentication. https://supabase.com/docs/guides/auth/auth-mfa
  8. 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.

The route guard's decisionA request is let through if it is public; otherwise an unsigned request goes to sign-in, a route not in the matrix is refused, and a role the matrix does not list for the route is refused. Request/reviewPublic route?/ and /sign-inSigned in?valid sessionnoIn the matrix?route listedyesRole allowed?matrix screensyesOpenyesSign-in pageno403 refusedno403 refusednoAllow
The route guard's decision. Four questions in order. Only a public route, or a signed-in person whose role the matrix lists for that route, gets through.

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
// 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";
}
The generated route tableEight routes: two public, five opened by the matrix to named roles, and a new route that nobody can open until the matrix lists it. RouteSourceWho may open it/publiceveryone/sign-inpubliceveryone/operatormatrixoperator/reviewmatrixsupervisor/dashboardmatrixsupervisor, manager/workmatrixsupervisor, maintenance/peoplematrixaccess_admin/reports (new)not listednobody until the matrix opens it
The generated route table. A new /reports page is refused for everyone until a reviewed change to the matrix opens it.

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

Three locksA request passes a route guard, then a server check next to the data, then row-level security in the database. Requestwith session cookie1 · Route guardproxy.ts, before any pageclosed unless the matrix opensthe route2 · Server checkin each server action and APIroutechecks the role again, next tothe data3 · Row-level securityinside the database (Session 7)returns only the rows in scopeAny one lock can have a bug. All three would have to fail together for data to leak.
Three locks. The route guard, a server check in every server action and API route, and row-level security. A leak needs all three to fail at once.
  1. Route guard (proxy.ts): closed unless the matrix opens the route.
  2. 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.
  3. 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?

Exercise · Generate and wire the route guard35 minutes

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

  1. OWASP Authorization Cheat Sheet (deny by default). https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
  2. Next.js Docs: proxy.js file convention. https://nextjs.org/docs/app/api-reference/file-conventions/proxy
  3. Supabase Docs: Setting up server-side Auth for Next.js. https://supabase.com/docs/guides/auth/server-side/nextjs
  4. NIST National Vulnerability Database: CVE-2025-29927 (Next.js middleware authorization bypass). https://nvd.nist.gov/vuln/detail/CVE-2025-29927
  5. Next.js Docs: Authentication guide (optimistic checks in Proxy, Data Access Layer). https://nextjs.org/docs/app/guides/authentication
  6. 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

TestRequestExpected
Signed outOpen /review with no sessionRedirect to /sign-in
Wrong roleOpen /review signed in as an operator403 Not allowed
Expired sessionOpen /operator with an expired or tampered tokenRedirect to /sign-in
Not in the matrixOpen a route that exists but is not listed403 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
// 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.
Exercise · Prove default deny in CI25 minutes

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

  1. OWASP Session Management Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
  2. 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

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

1. What is the difference between authentication and authorisation?
2. Why is Supabase's access token short-lived, with a refresh token to renew it?
3. Which roles must use two-step sign-in in this course?
4. Operators share one tablet per line. How should they sign in?
5. Under default deny, which routes are open to everyone?
6. An agent adds /reports and does not touch the matrix. What does the route guard do?
7. In Next.js 16, which file runs before every page and holds the route guard?
8. Why does every server action also check the role, as well as the route guard?
9. Where should the route guard get the person's roles from?
10. A test opens /operator with an expired token. What must happen?
11. What does the route completeness test check?
12. A supervisor leaves the company today. What happens?