Skip to content

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.

  • API keys travel in the X-SSEA-Key HTTP 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_keys table (column key_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 with HTTP 401.
  • Revocation: set is_active = 0 on the api_keys row; takes effect immediately.
  • 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.

Endpoints under /v1/tenants/:id/viewer/** additionally require:

  • X-SSEA-Viewer-Context: base64url-encoded JSON payload with tenant_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.

Every database query mandatorily filters by tenant_id:

WHERE tenant_id = ?1

The dashboard API additionally verifies the authenticated tenant matches the requested resource:

if (tenantId !== auth.tenant_id) return 403 Forbidden

No admin bypass endpoint exists in production code. A tenant can never read another tenant’s data.

  • 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_ip option that excludes the IP entirely from the payload before sending — useful if your internal policy forbids data leaving the origin with IPs.

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.

PhaseMechanism
In transitTLS 1.3 between Moodle plugin ↔ ingest, ingest ↔ queue, queue ↔ processor, dashboard ↔ API. Certificates managed by Cloudflare.
At restData in Cloudflare D1 (SQLite) and Cloudflare R2 (object storage) is encrypted at rest by default (AES-256).
Keys and secretsManaged as Cloudflare Workers secrets. Never in wrangler.toml, logs, or version control.

All HTTP responses include:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
X-Request-IDUnique 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.
ParameterDefault value
Requests per window (per tenant)1,000
Window60 seconds
StorageCloudflare 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.

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.

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.

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.