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
| Property | Value |
|---|---|
| Algorithm | PBKDF2 (Password-Based Key Derivation Function 2) |
| Hash function | SHA-256 |
| Iterations | 100,000 |
| Storage format | pbkdf2:<hexSalt>:<hexHash> |
| Salt | Random, 32 bytes, generated via crypto.getRandomValues |
| Location | workers/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
| Token | Algorithm | TTL | Library |
|---|---|---|---|
| Access Token | JWT HS256 | 15 minutes | jose |
| Refresh Token | Opaque (SHA-256 of stored hash) | 7 days | jose |
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
| Data | Field | Location |
|---|---|---|
| Student change payload | encryptedChangePayload | studentEvents (IndexedDB) |
| Deletion justification | encryptedJustification | studentEvents (IndexedDB) |
| Refresh token + CSRF token | encryptedSessionSecrets | sessions (IndexedDB) |
| Password hash (credentials) | encryptedCredentials | users (IndexedDB) |
Key derivation
- The user logs in with email + password.
- The frontend computes
SHA-256(password)— the plaintext password is never stored. - A 256-bit AES-GCM key is derived via PBKDF2 (600k iterations on the frontend,
encryptionService.ts) from the password hash. - The key is kept in memory (
Map<userId, KeyContext>) and never persisted to disk. - 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
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
X-XSS-Protection | 0 (disabled in favor of CSP) |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), 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:
| Route | Window | Maximum requests |
|---|---|---|
POST /api/v1/auth/login | 60 seconds | 5 (via middleware pipeline) |
POST /api/v1/auth/refresh | 60 seconds | 10 (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_ENVinjected via Vitedefine, withoutVITE_prefix to avoid conflicts withimport.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 createCommandHandler → applyEvent. This preserves the audit trail and meets LGPD traceability requirements.
Audit trail
The audit_logs table records every relevant operation with the fields:
| Field | Description |
|---|---|
audit_id | Audit entry UUID |
correlation_id | Request correlation ID |
actor_user_id | User who performed the action |
actor_role | User's role at the time |
endpoint | Route accessed |
outcome | Result (success, error, etc.) |
details | JSON payload with operation details |
created_at | ISO 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
| Secret | Location | Purpose |
|---|---|---|
JWT_SECRET | Cloudflare Secrets / .dev.vars | JWT token signing |
SEED_PASSWORD | .dev.vars (dev only) | Seed user password |
CLOUDFLARE_API_TOKEN | CI Secrets / .dev.vars | Deploy via Wrangler |
VITE_GOOGLE_MAPS_API_KEY | .env.local (frontend) | Google Maps Places API |
DEPLOY_ENV | Cloudflare Pages Secrets | Deploy 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:
| Severity | Issues | Status |
|---|---|---|
| 🔴 CRITICAL | 3 (JWT hardening, _seed protection, timingSafeEqual) | ✅ Resolved |
| 🟠 HIGH | 4 (sessionStorage, dangerousInnerHTML, rate limiting, error leak) | ✅ Resolved |
| 🟡 MEDIUM | 5 (device ID, CSRF, role sync, token notification, CORS) | ✅ Resolved / Decided not to implement |
| 🔵 LOW | 2 (cookie secure, security.txt) | ✅ Resolved |
| ⚪ INFO | 1 (JSX rendering) | ✅ wontfix |
References
- Security Policy — how to report vulnerabilities
- ADR-0003 — scrypt → PBKDF2 migration
- ADR-0004 — client-side encryption with AES-GCM
- ADR-0007 — JWT token validation
- Authentication Architecture — full auth flow
- Idempotency Guide — idempotency ledger
- LGPD — LGPD compliance
- STATE.md — complete audit history