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 jti tracking for revocation and refresh rotation — each refresh consumes and denies its own jti, 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_active is 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_admin tokens: 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
MFA and SSO/SAML/OIDC are not implemented yet — Phase 2, gating enterprise deals rather than launch. The credential-stuffing runbook currently ends at “force reset.”

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 if vault_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-src limited 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/EXPIRE race 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-audit runs 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)

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.
Enterprise auth is explicitly Phase 2 — self-service password reset is the only credential-recovery mechanism today.
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.

Multi-Tenancy

Authentication API