Security Guide
EduNest LMS is a single Next.js 16 (App Router) application backed by PostgreSQL. There is no separate API server — every protected action runs inside route handlers under /api/v1, and each one enforces its own authentication and authorization. This guide describes the security mechanisms that actually ship in the source you received, and how to harden a production install.
Table of Contents
- Authentication
- Authorization (RBAC)
- Secret Encryption
- License Verification
- API Documentation Protection
- Cron Protection
- Input & Query Handling
- Production Hardening Checklist
- Environment Variables
Authentication
EduNest uses stateless JWTs signed with HS256. Tokens are created and verified with the jose library (src/lib/auth.ts, src/lib/customer-auth.ts); the license client additionally uses jsonwebtoken for offline RS256 verification (see License Verification). There is no refresh token — when a token expires the client simply re-authenticates.
Token types
There are two independent session audiences, each in its own HttpOnly cookie:
| Audience | Cookie | Algorithm | Lifetime | Source |
|---|---|---|---|---|
| Admin / staff | admin_token |
HS256 | JWT_EXPIRES_IN (default 7d) |
src/lib/auth.ts |
| Learner / instructor | member_session |
HS256 | 30d (1d for short-lived flows) |
src/lib/customer-auth.ts |
Both are signed with the same JWT_SECRET. Cookies are set with HttpOnly, SameSite=Lax, and Path=/:
// src/lib/auth.ts
`${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 3600}`
Native / Expo clients may send the learner session as
Authorization: Bearer <jwt>instead of the cookie; the sameJWT_SECRETverifies either form.
Password hashing
Passwords are hashed with bcryptjs at cost factor 10 before storage, and only ever compared with bcrypt.compare — plaintext is never persisted. This applies uniformly across admin login, the learner/instructor portal, password changes, password resets, and the install wizard:
// e.g. src/app/api/v1/admin/users/route.ts, portal/auth/register, install/admin
const hash = await bcrypt.hash(password, 10);
// on login:
const valid = await bcrypt.compare(password, user.password);
JWT_SECRET
The signing secret is read from the environment at runtime (lazily, so the install wizard can write it before first use). Set a long, random value:
JWT_SECRET=replace-with-a-long-random-string
JWT_EXPIRES_IN=7d
Rotating
JWT_SECRETinvalidates every existingadmin_tokenandmember_sessionat once, forcing all users to log in again — the fastest way to revoke all sessions if a leak is suspected.
Authorization (RBAC)
Authorization for admin/staff actions is enforced per route by requirePermission(action, resource) from src/lib/require-permission.ts. Each protected route handler calls it at the top and returns early on denial:
// pattern used throughout src/app/api/v1/admin/**
const denied = await requirePermission("update", "courses");
if (denied) return denied; // 403 Forbidden
Important:
src/proxy.tscontains aproxy()function with Basic-auth and cookie checks, but the project does not ship amiddleware.tswiring it in. The real, enforced authorization boundary is the per-routerequirePermission/getSessioncall inside each handler — not edge middleware. Treat every admin route handler as self-guarding.
How a decision is made
requirePermission resolves the caller's effective permissions (effectivePermissions()):
- Read the current session from the
admin_tokencookie (getSession). - Look up the user's
rolestring androle_id. - If
role_idpoints at a row inroles, load that role'spermissionsarray. - Decide super-admin status, then check the specific
resource:actionpermission.
const isSuper =
roleId == null || // no custom role → full access
roleIsSystem || // system role (e.g. Administrator)
roleName?.toLowerCase() === "administrator" ||
SUPER_ROLE_STRINGS.has(roleStr); // VENDOR_ADMIN / SUPER / SUPERADMIN
| Action | Permission string checked |
|---|---|
read |
<resource>:read |
create |
<resource>:create |
update |
<resource>:update |
delete |
<resource>:delete |
A super-admin passes every check. Any other user must hold the exact resource:action string in their role's permission list, otherwise the route returns 403 Forbidden.
Audit emission
For mutating actions (create/update/delete), requirePermission emits an admin.action event via the plugin hook bus (emit("admin.action", { actorId, action, resource })). Emission is best-effort and isolated — it never throws and never blocks the route, so an audit plugin can subscribe without affecting the request path.
Learner & instructor access
Learner and instructor APIs authenticate the member_session JWT and self-guard ownership/approval (e.g. instructor routes additionally verify the instructor is approved and owns the resource).
Secret Encryption
Third-party integration credentials (storage keys, payment keys, mail credentials, etc.) are stored encrypted at rest in the database. src/lib/secrets.ts implements AES-256-GCM:
- The key is derived as
SHA-256(SECRET_KEY)(falling back toJWT_SECRET, then a dev default). - Each value uses a fresh random 12-byte IV and a 16-byte GCM auth tag.
- Ciphertext is stored as
enc:v1:<base64(iv | authTag | ciphertext)>.
// src/lib/secrets.ts
const KEY = crypto.createHash("sha256")
.update(process.env.SECRET_KEY || process.env.JWT_SECRET || "app-dev-secret")
.digest();
// encryptSecret / decryptSecret use aes-256-gcm with a per-value random IV
Helpers encryptJson / decryptJson handle whole config objects. The storage layer (src/lib/r2.ts) reads the active storage connection's config column through decryptJson, so credentials never sit in plaintext in the database.
Set a strong, unique
SECRET_KEY. Because it derives the encryption key, changing it makes every previously-encrypted credential unreadable — you would need to re-enter integration secrets in the admin panel.
License Verification
EduNest ships with the CreativeCape license client (src/lib/license/). Verification is primarily offline:
- Activation binds a purchase code to the domain and returns an RS256 JWT from
creative-cape.com. - On each request the token is verified offline with a bundled RS256 public key (
jwt.verify(token, publicKey, { algorithms: ["RS256"] })) — signature, expiry, and domain binding. - A daily heartbeat re-validates against the server in the background (fire-and-forget).
- If the server is unreachable, the license keeps working through a 14-day grace window (
GRACE_MS = 14 * DAY) so a network blip never takes a live site down. - The resolved status is cached 60 s in-process for hot public pages.
// src/lib/license/index.ts
const GRACE_MS = 14 * DAY; // grace when the license server is unreachable
const TTL = 60_000; // status cache for hot pages
Development hosts (localhost, 127.0.0.1, *.test, private LAN ranges) bypass licensing entirely and unlock all premium add-ons for building and testing. Enforcement is intentionally scoped to an admin-panel lock (LicenseGate) plus a public-site banner (LicenseBanner) — not a full app block. The matching private key exists only on the license server; the public key is baked in, and LICENSE_PUBLIC_KEY / LICENSE_SERVER_URL may override the defaults (e.g. for key rotation) without a code change.
API Documentation Protection
The Swagger UI at /api-docs and the spec at /api/openapi.json are gated behind HTTP Basic Auth (src/proxy.ts):
const DOCS_USER = process.env.API_DOCS_USER ?? "admin";
const DOCS_PASS = process.env.API_DOCS_PASS ?? "Password@1";
// WWW-Authenticate: Basic realm="EduNest API Docs"
The defaults (
admin/Password@1) are for first-run convenience only. Always overrideAPI_DOCS_USERandAPI_DOCS_PASSin production, or your API surface documentation is publicly readable.
Cron Protection
The scheduled task runner (src/app/api/v1/cron) is protected by a shared secret, CRON_SECRET. A request is authorized only if it presents the secret as a bearer header or a query key:
// src/app/api/v1/cron/route.ts
const secret = process.env.CRON_SECRET || "";
if (!secret) return false; // unset → endpoint stays closed
if (authorizationHeader === `Bearer ${secret}`) return true;
return searchParams.get("key") === secret;
This matches Vercel Cron (which sends Authorization: Bearer <CRON_SECRET>) and any external scheduler. With no CRON_SECRET set, the endpoint refuses every request (fails closed). The cron tasks (delayed review requests, abandoned-cart reminders, expired chat-attachment purge, monthly payouts) are each idempotent and row-bounded.
Input & Query Handling
- Parameterized SQL. Database access uses
@neondatabase/serverlesstagged-template queries (sql\SELECT ... WHERE id = ${id}`) and arawSql(text, params)helper (src/lib/db.ts`). Values are bound as parameters, not string-concatenated, which prevents SQL injection in the normal data path. - React escaping. Output is rendered by React, which escapes interpolated text by default.
- Error shaping. Route handlers return JSON errors via shared helpers (
ok/err/serverErr) rather than leaking stack traces.
The backup export (
src/app/api/v1/admin/backup) builds a literal SQL dump by escaping values itself; this code path runs only for an authorized admin and produces a download, not a live query.
Production Hardening Checklist
- Set a long, random
JWT_SECRET(rotating it logs everyone out). - Set a strong, unique
SECRET_KEYbefore entering any integration credentials. - Override the API-docs Basic-auth defaults:
API_DOCS_USER,API_DOCS_PASS. - Set a strong
CRON_SECRET(without it the cron endpoint is closed; with a weak one it is guessable). - Serve everything over HTTPS so cookies and bearer tokens are never sent in cleartext.
- Create least-privilege staff roles in the admin panel — grant only the
resource:actionpermissions each role needs; reserve super-admin / Administrator for trusted operators. - Keep
DATABASE_URLand any.envfile out of version control and off public hosts — they are secrets. - Lock down the storage bucket: serve only intended public prefixes publicly; keep credentials in the encrypted DB config or
R2_*env vars, not in code.
Environment Variables
Security-relevant variables actually read by the code:
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL (Neon) connection string |
JWT_SECRET |
Signs/verifies admin_token and member_session JWTs |
JWT_EXPIRES_IN |
Admin token lifetime (default 7d) |
SECRET_KEY |
Derives the AES-256-GCM key for encrypted credentials |
CRON_SECRET |
Authorizes the cron endpoint (unset → closed) |
API_DOCS_USER / API_DOCS_PASS |
Basic auth for /api-docs and /api/openapi.json |
LICENSE_SERVER_URL |
License server base URL (default https://creative-cape.com) |
LICENSE_PUBLIC_KEY |
Optional override of the bundled RS256 public key |
R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_DEFAULT_BUCKET, R2_PUBLIC_URL |
Legacy storage fallback when no DB storage connection is configured |
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com