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

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=/:

TS
// 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 same JWT_SECRET verifies 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:

TS
// 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:

ENV
JWT_SECRET=replace-with-a-long-random-string
JWT_EXPIRES_IN=7d

Rotating JWT_SECRET invalidates every existing admin_token and member_session at 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:

TS
// pattern used throughout src/app/api/v1/admin/**
const denied = await requirePermission("update", "courses");
if (denied) return denied; // 403 Forbidden

Important: src/proxy.ts contains a proxy() function with Basic-auth and cookie checks, but the project does not ship a middleware.ts wiring it in. The real, enforced authorization boundary is the per-route requirePermission / getSession call 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()):

  1. Read the current session from the admin_token cookie (getSession).
  2. Look up the user's role string and role_id.
  3. If role_id points at a row in roles, load that role's permissions array.
  4. Decide super-admin status, then check the specific resource:action permission.
TS
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 to JWT_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)>.
TS
// 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.
TS
// 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):

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 override API_DOCS_USER and API_DOCS_PASS in 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:

TS
// 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/serverless tagged-template queries (sql\SELECT ... WHERE id = ${id}`) and a rawSql(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_KEY before 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:action permissions each role needs; reserve super-admin / Administrator for trusted operators.
  • Keep DATABASE_URL and any .env file 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