Skip to content

Security

This page consolidates all Neemias security measures, covering authentication, encryption at rest, network security, API protection, data protection, and infrastructure. The project underwent a security audit with 14 findings resolved (see STATE.md for the complete history).

To report vulnerabilities, see the Security Policy.


1. Authentication

Password Hashing — PBKDF2

PropertyValue
AlgorithmPBKDF2 (Password-Based Key Derivation Function 2)
Hash functionSHA-256
Iterations100,000
Storage formatpbkdf2:<hexSalt>:<hexHash>
SaltRandom, 32 bytes, generated via crypto.getRandomValues
Locationworkers/src/modules/auth/password.ts

The 100k iterations were chosen to maintain compatibility with the 10 ms CPU budget of the Cloudflare Workers Free Tier. The legacy backend (Fastify) used scrypt, which proved incompatible with the Workers runtime — the migration was resolved in ADR-0003.

Each hash contains an independent random salt, preventing rainbow table attacks even if two users use the same password.

JWT Tokens

TokenAlgorithmTTLLibrary
Access TokenJWT HS25615 minutesjose
Refresh TokenOpaque (SHA-256 of stored hash)7 daysjose

The signing secret (JWT_SECRET) is configured as a Cloudflare environment variable (wrangler.toml / .dev.vars). It is never exposed to the frontend or committed to the repository.

Refresh Token Rotation

Each use of a refresh token invalidates the previous one and generates a new pair. This detects malicious reuse: if an already-revoked refresh token is presented, the session is marked as suspected_compromise and all tokens for that user are revoked. The frontend reacts by attempting full re-authentication.

CSRF

In Bearer mode (default), CSRF protection is inherent — the Authorization: Bearer header is not automatically sent by browsers in cross-origin navigation. The X-CSRF-Token header is kept as an additional defense, generated per session and validated on the backend.


2. Encryption at Rest

Sensitive data stored locally in IndexedDB is protected with 256-bit AES-GCM encryption.

What is encrypted

DataFieldLocation
Student change payloadencryptedChangePayloadstudentEvents (IndexedDB)
Deletion justificationencryptedJustificationstudentEvents (IndexedDB)
Refresh token + CSRF tokenencryptedSessionSecretssessions (IndexedDB)
Password hash (credentials)encryptedCredentialsusers (IndexedDB)

Key derivation

  1. The user logs in with email + password.
  2. The frontend computes SHA-256(password)the plaintext password is never stored.
  3. A 256-bit AES-GCM key is derived via PBKDF2 (600k iterations on the frontend, encryptionService.ts) from the password hash.
  4. The key is kept in memory (Map<userId, KeyContext>) and never persisted to disk.
  5. Upon logout or session expiry, the key is removed from memory.

Versioning and rotation

Each encrypted envelope carries a keyVersion. When the user logs out/logs in, the key version is incremented and all envelopes of the old version are re-encrypted with the new key (rotateEncryptedDataForUser). If the envelope version does not match the cached version, the system throws KEY_VERSION_MISMATCH, forcing re-derivation.

See ADR-0004 for the full architecture decision.


3. Network Security

HTTPS

All communication between frontend and backend is exclusively HTTPS. The Strict-Transport-Security (HSTS) header is configured with max-age=63072000; includeSubDomains; preload, ensuring browsers never attempt HTTP for the domain.

Content Security Policy (CSP)

Implemented via security-headers.ts middleware on every Worker response:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';
  img-src 'self' data: https:; connect-src 'self'; font-src 'self' data:;
  object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Restrictive policy: scripts, styles, and connections only from the same domain. Images allow data: URIs (for inline avatars) and https: (for external profile photos). object-src 'none' and frame-ancestors 'none' prevent plugin attacks and clickjacking.

Additional security headers

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
X-XSS-Protection0 (disabled in favor of CSP)
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=(), interest-cohort=()

CORS

Configured with explicit origins via cors.ts. The Access-Control-Allow-Origin header reflects the request origin (does not use wildcard * in production). Allowed headers: Authorization, Content-Type, X-Device-Id, X-Csrf-Token, X-Idempotency-Key. Preflight (OPTIONS) responds with 204 No Content and 24-hour cache (Access-Control-Max-Age: 86400).


4. API Security

Required Idempotency

Every mutation (POST, PATCH) requires the Idempotency-Key header with a UUID v4. The backend maintains a ledger in D1 (idempotency_ledger) indexed by (key, user_id) with SHA-256 hash of the payload. This ensures that:

  • Repeated requests with the same key and same payload return the original response (idempotent).
  • Same key with different payload returns HTTP 409 Conflict (malicious replay detection).
  • Network timeouts and retries do not cause data duplication.

See the Idempotency guide for full details.

X-Device-Id Header

Every authenticated request must include the X-Device-Id header. The authentication middleware (auth.ts) rejects requests without this header with HTTP 400. This binds tokens to specific devices, making it harder to reuse stolen tokens on other devices.

Rate limiting

Rate limiting is implemented with D1 persistence (rate_limits) to share counters across Cloudflare edge locations. Current configuration:

RouteWindowMaximum requests
POST /api/v1/auth/login60 seconds5 (via middleware pipeline)
POST /api/v1/auth/refresh60 seconds10 (via middleware pipeline)

Exceeded limits return HTTP 429 Too Many Requests with Retry-After header. The system is fail-open: if D1 is unavailable, requests are allowed (Cloudflare's WAF acts as primary protection). Expired entries are periodically cleaned every ~100 checks.

Secrets

All credentials and secrets are injected via Cloudflare environment variables:

  • Local development: .dev.vars (gitignored, never committed).
  • Production: Cloudflare Secrets (dashboard or wrangler secret put).
  • Build-time: DEPLOY_ENV injected via Vite define, without VITE_ prefix to avoid conflicts with import.meta.env.

5. Data Protection

Soft-delete with justification

Students are never physically removed from the database. The delete operation (DELETE /api/v1/students/:id) performs a soft-delete: updates status = 'DELETED' and records deleted_justification. An immutable SOFT_DELETE event is created in the unified events table via createCommandHandlerapplyEvent. This preserves the audit trail and meets LGPD traceability requirements.

Audit trail

The audit_logs table records every relevant operation with the fields:

FieldDescription
audit_idAudit entry UUID
correlation_idRequest correlation ID
actor_user_idUser who performed the action
actor_roleUser's role at the time
endpointRoute accessed
outcomeResult (success, error, etc.)
detailsJSON payload with operation details
created_atISO 8601 timestamp

Attendance events (attendance_events) and student events (student_events) are immutable — once created, they are never altered or removed.

PII data classification (LGPD)

9 student fields are classified as PII: displayName, photoRef, guardianName, guardianNameAlt, birthDate, phones, address.*, allergies, specialNeeds. See the LGPD page for full compliance measure details.


6. Infrastructure

Cloudflare WAF

Cloudflare's Web Application Firewall provides an additional layer of protection against common attacks (SQL injection, XSS, DDoS) before requests reach the Worker. Rate limiting in the Worker acts as a second line of defense.

Automatic backups (D1)

Cloudflare D1 performs automatic point-in-time snapshots, allowing restoration of the database to any moment within the retention window. Daily backups and weekly copies are managed by the platform — no additional configuration required. See Backup and Restore.

Secrets and environment

SecretLocationPurpose
JWT_SECRETCloudflare Secrets / .dev.varsJWT token signing
SEED_PASSWORD.dev.vars (dev only)Seed user password
CLOUDFLARE_API_TOKENCI Secrets / .dev.varsDeploy via Wrangler
VITE_GOOGLE_MAPS_API_KEY.env.local (frontend)Google Maps Places API
DEPLOY_ENVCloudflare Pages SecretsDeploy environment (dev/staging/prod)

security.txt

Implemented per RFC 9116. Available at /.well-known/security.txt, it allows security researchers to easily find the correct channel for reporting vulnerabilities.


Audit findings summary

The security audit identified 14 issues, all resolved:

SeverityIssuesStatus
🔴 CRITICAL3 (JWT hardening, _seed protection, timingSafeEqual)✅ Resolved
🟠 HIGH4 (sessionStorage, dangerousInnerHTML, rate limiting, error leak)✅ Resolved
🟡 MEDIUM5 (device ID, CSRF, role sync, token notification, CORS)✅ Resolved / Decided not to implement
🔵 LOW2 (cookie secure, security.txt)✅ Resolved
⚪ INFO1 (JSX rendering)✅ wontfix

References

Distributed under MIT License.