Security Guide
SkoolyHub holds children's records, family contact details, medical notes and money. The model below is what keeps each of those visible only to the people entitled to see them.
Sessions
| Who | Cookie | Algorithm | Lifetime |
|---|---|---|---|
| Administrator | admin_session |
HS256 | 30 days |
| Guardian | parent_session |
HS256 | 30 days |
| Staff | staff_session |
HS256 | 30 days |
| Student | student_session |
HS256 | 30 days |
Mobile clients send the same JWT as Authorization: Bearer <jwt>.
The token carries its role. A guardian token replayed against a teacher endpoint is rejected before any query runs — not partially served. This is checked in getGuardianId / getStaffId / getStudentId, so it cannot be forgotten in a route.
Cookies are httpOnly and SameSite. JWT_SECRET must be set in production; the app fails fast rather than falling back to a default.
Passwords
Hashed with bcrypt at cost 10, compared only with bcrypt.compare. Plaintext is never stored, and no endpoint returns a hash — the student detail endpoint explicitly deletes password_hash before responding, because an API that hands out hashes is one log dump away from an offline attack.
Staff cannot set anyone else's password. Guardians and teachers both set their own via a single-use 6-digit code sent to the contact detail the school already holds. No administrator ever learns a parent's or a teacher's credential.
Enumeration
Endpoints that take an identifier answer identically whether or not it exists:
- Login failures are indistinguishable from unknown accounts.
request-otpalways returns 200. A 404 would answer "is this phone number a parent at this school", which is exactly the question that must not be answerable.
Authorization
Every route guards auth first, then scopes.
// Admin
const denied = await requirePermission("update", "students");
if (denied) return denied;
// Parent — resolves the caller's own children
const ctx = await parentContext(req);
if ("error" in ctx) return ctx.error;
// Teacher — resolves the sections they cover
const ctx = await teacherContext(req);
Object-level checks, not just route-level. Every fetch or mutation by id includes the ownership condition:
JOIN student_guardians sg ON sg.student_id = i.student_id AND sg.guardian_id = ${guardianId}
?student=<public_id> is verified against the caller's children rather than trusted. An unknown or someone else's id yields 404 — never a silent fall-through to "all children", which would leak, and never an empty result the app would render as "no data".
Opaque identifiers
URLs expose a random public_id, never a sequential integer. Incrementing an id in a URL finds nothing, and the id in a link reveals nothing about how many students the school has.
The tables permitted to have one are an explicit allowlist in @/lib/public-id, which also guards the dynamic identifier interpolation against injection.
Money
| Guarantee | How |
|---|---|
| A payment cannot exceed the balance | recordPayment clamps to the outstanding amount |
| The invoice cannot be faked paid | paid_cents and status are recomputed from the payment ledger, never set |
| A gateway callback cannot decide the amount | Settlement credits the amount stored on the attempt when the payment started |
| A retried callback cannot double-credit | Settlement is idempotent; a settled attempt returns its original receipt |
| A guessed password cannot transact | Payment requires a verified guardian |
Marks and results
Marks are clamped to the paper's maximum. Grades derive from the grade scale, never from a request. Results require the report card and its exam to be published, so a half-marked exam cannot leak.
Teachers enter marks; the school publishes. That separation is a security boundary, not just a workflow.
SQL
All queries are parameterised — the tagged template, or rawSql(query, params) with $1 placeholders. User input is never concatenated into SQL, and table or column names are never built from input.
Uploads
Type and size are validated server-side. Images are re-encoded through the server pipeline, which drops any embedded payload along with the original. Storage keys are sanitised so a filename cannot escape its prefix. Raw-key writes are admin-only — otherwise a signed-in parent could overwrite the school's logo.
Rate limiting
Per IP and per handle on authentication and public write endpoints. The per-handle limit is the real brute-force guard: a 6-digit code is a million possibilities, but only if an attacker cannot try them quickly.
Stored credentials
Storage, payment, email and SMS credentials live encrypted in integration_connections, keyed on SECRET_KEY (falling back to JWT_SECRET).
Two consequences:
- A database dump carries them. Treat a dump as a live-credential file.
- Restoring into a deployment with a different secret leaves them unreadable — which is a useful way to move data without moving live payment keys.
Operational checklist
-
JWT_SECRETis long, random, and unique to this deployment - The default admin password has been changed
- HTTPS is enforced
- Demo seed data is absent from a real school's database
- Admin roles grant only what each person needs
- Backups are encrypted at rest and access to them is limited
-
.envis not committed
Reporting a vulnerability
Report privately to the vendor rather than in a public issue, with enough detail to reproduce.