Authentication
- Password hashing: bcrypt with a SHA-256 prehash (avoids bcrypt’s 72-byte truncation silently discarding part of a long password)
- Tokens: JWT, HS256, with
jtitracking for revocation and refresh rotation — each refresh consumes and denies its ownjti, so a replayed refresh token is rejected even before its natural expiry - Dashboard sessions: refresh tokens live only in an httpOnly, SameSite cookie set by Next.js route handlers — the only code that ever sees one. The dashboard’s server components read the cookie directly (no client-side-only route guard, no blank-flash on first paint)
- Session revocation:
_check_session_activeis Redis-cached (short TTL) rather than a database round-trip on every authenticated request — cache failures degrade to the database check, never the other way super_admintokens: additionally tracked in a Redis denylist, so the highest-privilege credential can be revoked before its natural expiry, not just bounded by a short TTL- Password reset: single-use, hashed, time-limited tokens; a successful reset invalidates every other active session for that user
Secrets
All credentials — JWT signing keys, LLM provider keys, per-tenant integration credentials — live in HashiCorp Vault. PostgreSQL never holds a secret value, only a Vault path reference. Production configuration validation refuses to boot ifvault_approle_role_id is
unset or a vault_dev_token is present — dev-mode Vault auth is
structurally impossible to ship to production by accident.
Tenant isolation
The strongest part of MAEL’s security story — see Multi-Tenancy for the full detail: RLS enforced at the database role level, a fail-closed sentinel tenant, and cross-tenant isolation asserted directly against a real database in the test suite, not mocked.Network and transport
- Webhook verification: timing-safe comparison (no early-exit signature check that leaks timing information)
- CORS: production configuration validation rejects a wildcard or localhost origin at boot — a misconfigured CORS value fails closed, not silently
- Dashboard CSP:
default-src 'self',connect-srclimited to the configured API origin,frame-ancestors 'none'— plus the standard security-header set (HSTS, nosniff,X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy) - Rate limiting: an atomic Lua token bucket (no
INCR/EXPIRErace condition), tiered per plan, with attacker-facing rate-limit headers suppressed so a 429 doesn’t leak exactly how close to the limit an attacker is
Supply chain
pip-auditruns in CI against the exact locked dependency set — every accepted-risk CVE is individually justified and documented, not a blanket ignore- Trivy scans the built container image; the CI gate only blocks on fixable HIGH/CRITICAL findings
ruff --select S(security lint rules) is kept clean, with any necessary suppressions individually reviewed
Known gaps (tracked, not hidden)
WebSocket auth token in query string
WebSocket auth token in query string
The workflow-run WebSocket accepts its auth token as a
?token= query
parameter — a documented browser limitation (WebSocket connections
can’t set custom headers), but query strings can land in proxy/load
balancer access logs. Tracked fix: a short-lived one-time WS ticket
endpoint, or moving the token into the first WS message.No SSO/MFA/SCIM
No SSO/MFA/SCIM
Enterprise auth is explicitly Phase 2 — self-service password reset is
the only credential-recovery mechanism today.
24-hour backup RPO
24-hour backup RPO
Nightly logical dumps only, no continuous WAL archiving (PITR) yet —
up to 24 hours of tenant content is the real loss window in a
worst-case restore. See Deployment.