Security & privacy
SoftSys Edu Analytics applies a defense-in-depth security model: authentication at every layer, CORS allowlisting in production, secrets validated at worker startup, and security headers on every response.
Authentication
Section titled “Authentication”API keys (X-SSEA-Key)
Section titled “API keys (X-SSEA-Key)”- API keys travel in the
X-SSEA-KeyHTTP header for service-to-service calls (e.g. from the Moodle plugin to the ingest endpoint). - HTTPS mandatory in production. Cloudflare always terminates TLS at the edge.
- Keys are stored as SHA-256 hashes in the
api_keystable (columnkey_hash). The plaintext value is never persisted after provisioning. - Format:
ssea_+ 32 hex characters (160 bits of entropy). - Optional expiry via
expires_at— expired keys are rejected withHTTP 401. - Revocation: set
is_active = 0on theapi_keysrow; takes effect immediately.
JWT tokens (dashboard API)
Section titled “JWT tokens (dashboard API)”- The dashboard API accepts Bearer JWTs (HS256) for browser ↔ API communication.
- JWTs are short-lived (1 hour by default) and signed with a secret managed by Cloudflare Workers.
- Expiry is checked on every request using
payload.exp. - Typical flow: the Moodle plugin signs a token with a shared secret and exchanges it for a session JWT via
POST /v1/auth/sso.
Viewer headers (Moodle-embedded reads)
Section titled “Viewer headers (Moodle-embedded reads)”Endpoints under /v1/tenants/:id/viewer/** additionally require:
X-SSEA-Viewer-Context: base64url-encoded JSON payload withtenant_id,role,moodle_user_id,exp.X-SSEA-Viewer-Signature: HMAC-SHA256 of the context, signed with the viewer shared secret configured in the plugin.
This lets a student or teacher see their own data from an iframe inside Moodle without holding a dashboard JWT.
Multi-tenant isolation
Section titled “Multi-tenant isolation”Every database query mandatorily filters by tenant_id:
WHERE tenant_id = ?1The dashboard API additionally verifies the authenticated tenant matches the requested resource:
if (tenantId !== auth.tenant_id) return 403 ForbiddenNo admin bypass endpoint exists in production code. A tenant can never read another tenant’s data.
Personal data (PII) handling
Section titled “Personal data (PII) handling”IP hashing
Section titled “IP hashing”- IP addresses are never stored in plaintext.
- The ingest worker computes
SHA-256(ip + salt)before queuing the event and discards the raw IP. - The salt is a Cloudflare Workers secret, rotatable.
- The Moodle plugin supports a
no_ipoption that excludes the IP entirely from the payload before sending — useful if your internal policy forbids data leaving the origin with IPs.
Moodle Privacy API
Section titled “Moodle Privacy API”The plugin exposes a standard classes/privacy/provider.php that integrates with Moodle’s native GDPR flow:
- Subject data export: users can export their information through Moodle’s standard flow.
- Right to be forgotten: when a user is deleted in Moodle, the plugin immediately stops generating events for that
user_id. - No local persistent data: the plugin’s only transient table (
local_ssea_queue) is flushed every 5 minutes when events are sent to ingest.
See Data retention for the full data lifecycle once ingested.
Encryption
Section titled “Encryption”| Phase | Mechanism |
|---|---|
| In transit | TLS 1.3 between Moodle plugin ↔ ingest, ingest ↔ queue, queue ↔ processor, dashboard ↔ API. Certificates managed by Cloudflare. |
| At rest | Data in Cloudflare D1 (SQLite) and Cloudflare R2 (object storage) is encrypted at rest by default (AES-256). |
| Keys and secrets | Managed as Cloudflare Workers secrets. Never in wrangler.toml, logs, or version control. |
Security headers
Section titled “Security headers”All HTTP responses include:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
X-Request-ID | Unique UUID per request (propagated if the client sends one, generated otherwise) |
CORS is controlled by the CORS_ALLOWED_ORIGINS environment variable (managed as a worker secret in production).
- Format: comma-separated list of origin patterns. Wildcards (
*) match one DNS label. - Production default:
https://*.softsysanalytics.com. - Development: also adds
http://localhost:*,http://127.0.0.1:*. - Disallowed origins receive
Access-Control-Allow-Origin: null. - All CORS responses include
Vary: Origin.
Rate limiting
Section titled “Rate limiting”| Parameter | Default value |
|---|---|
| Requests per window (per tenant) | 1,000 |
| Window | 60 seconds |
| Storage | Cloudflare KV |
Rate-limited requests receive HTTP 429 with a Retry-After header. Enterprise plans have higher limits (up to 10,000 req/60s), negotiated by contract.
See API — Event ingestion for how 429 errors surface on the client.
Traceability
Section titled “Traceability”Every response includes an X-Request-ID header (UUID). If the client sends an X-Request-ID in the request, the value is propagated and logged in every worker the request passes through. This allows correlating logs across backend components using a single identifier.
Config validation at startup
Section titled “Config validation at startup”Workers validate required secrets before handling any request. If a critical secret is missing, the worker responds:
HTTP 503 Service Unavailable{ "ok": false, "error": "Service unavailable — missing configuration", "code": "MISCONFIGURED"}This prevents silent failures where a missing secret causes unexpected behavior.
Vulnerability disclosure
Section titled “Vulnerability disclosure”If you discover a security vulnerability, email security@softsyssolutions.com.
Do not open a public issue on any platform for security reports.
We respond within 48 business hours and coordinate responsible disclosure.