I work as a Data Analyst at an independent K-12 girls' school (~1,100 students). In May the school needed a new parent portal, and I took it from an empty folder to a penetration-tested go-live in eight weeks: version 0.0.1 on 18 May, live to nearly 2,000 parents on 10 July. 282 commits, dozens of numbered releases from 0.0.1 to 0.1.59, one rule that never bent.
In a school, a single cross-family leak is an incident involving a child's data. That one constraint drove every decision in this article.
| Stack | |
|---|---|
| Framework | Next.js App Router (standalone output) |
| Language | TypeScript |
| Database | SQL Server via mssql (tedious) |
| Auth | Microsoft Entra ID SSO (OIDC + PKCE) |
| Hosting | on-prem Windows VM |
| Production dependencies | 4 total |
Try it: a public, mock-data demo of the real app is live at mydashboard-demo.vercel.app - fictional families, no login, same code.
This is the walkthrough: the architecture rule that held everything together, the go-live blocker that only a real parent could find, and what a 2-day external pentest did (and didn't) find.
Contents
- The deadline that started it - why build, and why the clock was real
- One rule: authentication is not authorization - the design idea everything hangs on
- Boring on purpose - 4 production dependencies, read-only by construction
- The bug only a real parent could find - the go-live blocker hiding in a token claim
- Sign-out on shared devices - a small fix for family iPads and managed devices
- Prove it, don't assert it - isolation under load, audit that fails closed
- The pentest - 2 days, 9 categories, 0 breaches
- Go-live - three gates and four days of margin
- Lessons learned - what generalizes beyond schools
The deadline that started it
The school's parent-facing intranet ran on Microsoft technology that reaches end of support on 14 July 2026. Parents' view of their daughters' information - attendance, timetables, assessment results, report PDFs, fee statements, mandatory data - was scattered across systems that the dying intranet barely surfaced.
Buy or build? The vendor options were compared seriously. But the data already lived in the school's own systems, on-premises, and the hard requirement was a guarantee no brochure makes: a signed-in parent sees their children, and only their children, every time, under load, forever.
So: empty folder, 18 May. Version 0.0.1 was a dashboard shell with per-daughter cards. Fifty-three days later, version 0.1.59 went live on 10 July - four days ahead of the cliff.
One rule: authentication is not authorization
The core design idea is that who you are and which children you may see are answered by two independent systems - and the second one never trusts the first.
- Authentication (who): Microsoft Entra ID via OIDC authorization-code flow with PKCE. SSO answers identity, full stop. The ID token is fully validated (signature via JWKS, audience, issuer, nonce, expiry), and the session is a signed HttpOnly cookie carrying nothing but an opaque parent ID and an 8-hour expiry.
- Authorization (which children): a separate query against the SIS resolves the set of children this person is a parent or guardian of - current year, active enrolment, parent flag set. That set is re-asserted at the SQL boundary on every single read. The database query itself is scoped to the authorized children, so even a forged child ID in a request returns nothing. The boundary fails closed.
-
A backend-for-frontend in between: the browser never talks to the database. Every read flows through a server layer with parameterized SQL only, and a
mock | sqlmode switch so every demo and shared surface runs on synthetic data. Live SQL exists only on the approved host.
The subtle part: which daughter is currently displayed is selection state, not authorization. Early versions carried it as a ?student= parameter in the URL - allowlist-validated and never able to expand the set; editing it to another family's student ID just fell back to her own daughter, and the attempt was audited. Later the ID left the URL entirely: a real student identifier sitting in the address bar, browser history, Referer headers, and screenshots is needless exposure in a child-data app, so the selection moved to per-tab sessionStorage and every link became a bare path. Either way, the server never trusted it - authorization is re-asserted on every API read regardless of what the client selects.
Fig 1 - Authentication and authorization as two independent systems: SSO proves who you are, the SIS decides which children you may see, and the SQL boundary re-checks it on every read.
Auth tells you who. The data boundary decides what they get.
This is a general multi-tenant authorization pattern, not a school thing - swap "parent" for "tenant" and "daughter" for "resource" and it is the same problem every SaaS has. If you enforce your tenancy boundary differently, I'd genuinely like to hear your approach (links at the end).
Boring on purpose
The production dependency list is four packages: next, react, react-dom, and mssql (the tedious-based SQL Server driver). Everything else is dev tooling. npm audit: 0 vulnerabilities, kept at 0.
That is not minimalism for style points. Every dependency is attack surface, and this app's threat model is "a stranger on the internet, one login away from children's data." Fewer moving parts meant the pentest scope was the code I wrote, not a tree of transitive packages I didn't.
Same logic elsewhere:
-
Read-only by construction. Every write path returns
405until write-back is separately approved and governed. You cannot exploit a write that does not exist. - Read-only SQL pools, no ORM. Parameterized queries only - zero string interpolation in any SQL constant.
- Data minimization at the mapper boundary. Medicare numbers masked to the last four digits, medical free-text withheld entirely, finance amounts kept inside the streamed PDF, insurance reduced to a boolean. The API never returns more than the screen needs.
- On-prem deployment I own end to end. A Windows VM running the Next.js standalone server on loopback under NSSM, with a TLS-terminating proxy in front. A load test showed nothing heavier was needed.
- Honest degraded states. If a data source is down, the UI says "Temporarily unavailable" - it never renders an empty list that could read as "no fees owing" or "no absences."
The mock | sql split has a nice side effect: the exact same app runs as a public demo on fictional families, so you can click through the dashboard, timetable, attendance, and reports yourself without any real child's data existing anywhere near it.
In a child-data system, boring is a security feature.
The bug only a real parent could find
By mid-June the portal was in live pilot. SSO worked. The test logins worked. Then, on 19 June, a supervised test with a real parent - sitting next to me, on the school network - failed. Five sign-in attempts, five denials: "Authenticated identity is not linked to a current parent."
Her data was perfect. Correctly linked, two current daughters, nothing stale. The deny path was doing exactly what it was designed to do - fail closed. The defect was that a legitimate parent was being denied.
The root cause was one line of claim precedence. The token verifier derived the sign-in identity as:
claims.email ?? claims.preferred_username ?? claims.upn
Looks harmless. Now look at what the two kinds of accounts actually carry in their ID token:
// Staff member - every account we had tested with:
{ email: "jane.doe@school.example", // school mailbox → matches the directory login ✅
upn: "jane.doe@school.example" }
// Real parent - ~99.6% of the actual user base:
{ email: "jane1975@gmail.com", // personal mailbox → matches nothing ❌
upn: "jane.doe@school.example" } // the right value, never consulted
The identity provider populates email from the account's mailbox - and for ~99.6% of parents that is a personal address. A personal mailbox can never match the school directory login that the authorization resolver keys on. The school UPN - the value that does match - was sitting right there in the token, never consulted, because email was present and won the ?? chain.
That is also why every earlier test passed: for staff, email and upn happen to agree, so deriving from email looked correct on every account we could test ourselves. The bug was invisible to every identity except the one that mattered - a real parent with a personal mailbox.
The fix reversed the precedence - school UPN first, email as last-resort fallback:
claims.upn ?? claims.preferred_username ?? claims.email
Authorization untouched. Regression tests added for exactly this token shape. Four days later, the same parent signed in under supervision and saw her own daughter's data - and the same account produced a clean A/B: mailbox-alias form denied, UPN form admitted. Go-live blocker closed, validated on a live token.
Test with the users you actually have, not the accounts you happen to hold.
Sign-out on shared devices
A much smaller piece of the work, but worth a note because school families often share a device - a family iPad, a managed school laptop. Two sign-out issues showed up in testing and in the audit stream: clearing only the portal's cookie left the identity provider's session alive (sign in again, land in the previous user's session), and on managed devices the device's own SSO silently re-admitted a parent seconds after she signed out. The fix was a few lines: sign-out lands on a terminal "you are signed out" screen instead of bouncing back into SSO, and the next sign-in carries prompt=login to force a credential prompt. Small fix - it just had to be correct on the devices families actually use.
Prove it, don't assert it
"No parent can see another family's child" is a claim. Claims are cheap. So I built a load harness that mints 100 concurrent sessions for 100 distinct parents, floods the app, and inspects every response for cross-family bleed.
Result: 0 errors, 0 cross-family leakage, p50 ≈ 463 ms on the production VM.
And the honest caveats went into the report next to the headline, because over-claiming performance would undermine the trust story:
- Cache hit-rate is 48% when the same parents return, but only 3% under a distinct-parent flood - real traffic sits between.
- An adversarial cold burst pushed p95 to ~5.5 s. Slow is acceptable; wrong is not.
The other "prove it" mechanism is the audit trail, and it fails closed: every child-data read writes a durable audit record - who, which child, which route, which session - before the response is returned. If the audit sink cannot be written, the data is not returned. The system can always answer "who looked at what," or it answers nothing. On top of that sits an access monitor watching for anomalies - one identity fanning out across many students, denial bursts, non-parent login bursts.
The regression net grew with every incident: 505 tests passing at 0.1.51, including a dedicated security suite of 77 tests covering the authorization matrix, SSO negative paths, session tampering, header spoofing, and audit-fail-closed behaviour. Every bug in this article became a permanent test.
The pentest
Before go-live, the school commissioned an independent firm for OWASP web application penetration testing: a 2-day authenticated external test across nine categories - configuration and leakage, business logic, authentication, authorization/IDOR, session management, input validation (XSS/SQLi), DoS, web services, AJAX.
A few known trade-offs were signed off before the test rather than left as surprises - things like session revocation and CSP strictness, weighed against their compensating controls. Documented trade-offs age better than hidden ones.
In the weeks before the test I shipped structured hardening waves: CSP tightening, no-store on the child-data API surface, constant-time key comparisons on operational endpoints, request body caps, generic client errors with detail kept server-side, and logout converted from GET to POST to close a forced-logout CSRF.
The result: passed - no breach. The testers could not penetrate the application, and cross-account escalation did not succeed. The written report went into governance as the citable artifact.
Go-live
Go-live was gated on three things, and nothing shipped until all three were green:
- A real parent, supervised, signs in via SSO and sees her own daughter's data - done 23 June (the claim-precedence validation above).
- The external pentest comes back clean - passed 27 June.
- Finance co-signs the fee figures the portal displays - confirmed 30 June.
One more number had to be checked before opening the doors: could every family actually get in? A sizing query across the full parent body found 0 families locked out - every current student had at least one sign-in-capable authorized parent. The residual was a short, named triage list of individual records for ICT, not a launch risk.
10 July 2026: live to the full parent body. Four days ahead of the Microsoft support cliff, fifty-three days after the first commit. Version 0.1.59 - 282 commits and dozens of numbered releases, each one a small, gated, tested step.
Fig 2 - Fifty-three days from empty folder to go-live: the pilot, the blocker a real parent exposed, three binding gates, and four days of margin on the deadline.
Lessons learned
- The tenancy boundary belongs in the data layer, not the application layer. Re-asserting the authorized scope at the query - and failing closed - is the multi-tenant authorization pattern that lets the app grow new surfaces without re-litigating trust each time. It generalizes to every multi-tenant system.
- Test with real users, not representative accounts. The staff account that "proved SSO worked" was structurally incapable of finding the bug that blocked go-live. The 99.6% case was invisible until an actual parent sat down.
- Sign-out has to stick on shared devices. A small fix, but schools, hospitals, and kiosks are full of family iPads and managed laptops.
- Publish the caveats next to the headline. "0 leaks at 100 concurrent parents" earns trust because it ships alongside "and here is where p95 got ugly."
- Accepted residuals beat hidden residuals. Signing off known trade-offs before the pentest turned would-be findings into documented decisions.
- Boring is a security feature. Four production dependencies, read-only by construction, parameterized everything. The most auditable code is the code that isn't there.
Trust isn't a launch milestone. It's the architecture.
Let's Connect
This was built solo, end to end - data contracts, BFF, SSO, hardening, load testing, deployment, runbooks - alongside a Master's in Software Engineering and AI. AI coding agents sped up the mechanical work; the security decisions stayed mine.
If you are building anything where one user must never see another user's data - multi-tenant SaaS, health, education, fintech - I'd genuinely like to compare notes.
Whether it's concrete or code, structure is everything.



Top comments (0)