A summary of rootmail's security model and the controls in place, from the Phase 6 hardening review. (For reporting a vulnerability, email security@rootmail.io.)
- Two credential types, hash-only at rest. API keys (
rm_live_…/rm_test_…) and dashboard session tokens (rms_…) are stored only as SHA-256 hashes; the raw value is shown once and never persisted. Passwords use scrypt (scrypt$salt$hash). - MFA (TOTP, RFC 6238). Optional per-user; enrollment requires confirming a code, issues 10 single-use recovery codes (scrypt-hashed), and login returns a short-lived signed challenge before a session is minted.
- SSO. Google/GitHub OAuth for all; SAML 2.0 for Enterprise orgs via
@node-saml/node-saml— the IdP's assertion signature is verified against the org's configured x509 cert (never hand-rolled), logins are bound to the connection's email domain, members are JIT-provisioned into the existing org, and optional per-domain enforcement disables password login. - SCIM 2.0 provisioning. Per-org bearer-token endpoints (
/scim/v2/Users) let the IdP create/update/deactivate members; a deprovisioned (inactive) membership grants no access — enforced centrally in workspace resolution, with the user's sessions killed on deactivate. - Brute-force controls. Per-identity login lockout (10 failures → 429 for 15 min,
on
/loginand/mfa/verify) and a per-IP sign-up cap (10/hour). - Public surface is explicit. Only
/health,/v1/webhooks/*,/v1/auth/*,/v1/unsubscribe,/assets/*,/v1/proof/*skip auth; everything else requires a valid key or session.
- RBAC permission matrix enforced on every mutating route via
requirePermission(e.g.messages.send,content.manage,webhooks.manage,members.manage,billing.manage,apikeys.manage,proof.read). API keys act as the account and resolve to all permissions; session users resolve to their role (incl. custom roles). - Plan/feature gating. Gated capabilities (sub-tenants, sequences, campaigns,
threads, RBAC, proof) return
402 feature_lockedwith an upgrade payload. The sub-tenant send path re-checks the feature so a downgraded org can't keep sending. - No IDOR. Every resource lookup by id is scoped to the caller's workspace or org
(
where(and(eq(table.id, id), eq(table.workspaceId | organizationId, …)))); mutations operate only on entities already loaded through that scoped check. Audited Phase 6. - First-send gate. Live sends require the org owner's email to be verified.
- All request bodies are validated with Zod (
parse()helper) before use. - Outbound dev webhooks are SSRF-guarded — target URLs are checked against
loopback/private ranges (
assertPublicUrl); a dev-onlyWEBHOOK_ALLOW_LOCALescape hatch exists for local catchers and must stay off in production. - Inbound webhooks are signature-verified. Stripe via
constructEvent; Amazon SNS via message-signature verification with an SSRF-guarded signing-cert host check. Both are idempotent (event ids / SNS MessageId deduped).
- Test-mode sends never reach a real ESP — they route to the mock provider, so synthetic recipients can't bounce the production sending domain.
- DKIM/SPF/DMARC on the sending domain; bounces/complaints feed suppression.
- Outbound webhook payloads carry ids/status only — no message content.
- Secrets live in gitignored
.env/apps/dashboard/.env.local;.env.exampleis placeholders only (committed). The dashboard holds the API key in an httpOnly cookie and only ever calls the API server-side. - Sub-tenant DKIM private keys are encrypted at rest (AES-256-GCM, keyed by
ENCRYPTION_KEY), so a database dump no longer hands over every tenant's signing key — which matters because DKIM keys have no expiry and, without rotation, no revocation. This is not KMS: the key lives in the application environment, so it defends against a stolen dump, backup or replica, not against host compromise. Moving to a KMS is the next step; every caller already goes throughencryptSecret/decryptSecretand none knows the algorithm. - Layer-3 proof bundles are Ed25519-signed and pin a
content_hashof the rendered HTML, so a message's lifecycle is independently verifiable.
- Global limit (300/min per key/IP) + tighter per-route caps on the AI endpoints, plus the auth lockout and sign-up cap above.
- Quota is atomic and per-organization. Monthly send usage is counted per org,
not per workspace, so spinning up extra workspaces or sub-tenants can't multiply a
plan's allowance. Free is hard-capped: the cap is reserved in a single conditional
UPDATE … WHERE emails_sent + n <= quota, so a burst of concurrent sends can't overshoot it (regression test:apps/api/scripts/test-quota.ts,pnpm --filter @rootmail/api test:quota). Anidempotency_keyreplay short-circuits before it counts; a concurrent duplicate that loses the insert race refunds its reservation, so retries never over-count. - Self-upgrade is fail-closed. In Stripe mode a plan change applies only after a
real Checkout session is created — a misconfigured price or a Stripe outage returns
an error instead of silently granting a free upgrade. The direct
POST /v1/billing/planswitch is rejected in Stripe mode. Paid add-ons (extra seats, dedicated IP, sub-tenant packs, AI-credit packs) are also refused via self-serve in Stripe mode until subscription-item billing is wired, so they can't be self-granted for free. - AI spend is capped and cost-aligned. AI drafts and the assistant meter against the
plan's AI-credit allowance (plus purchased packs), rate-limited (20/min and 10/min);
over-allowance calls return
402. Credits bill per model call (1 per token-bounded call: a draft = 1; an agentic assistant run = 1 per step, capped at 6), so a heavier request costs proportionally more — never the same flat rate regardless of work done. Output is capped (max_tokens) and input is length-limited, so each call's cost is bounded and a credit always covers it. - Test mode can't send real mail or consume quota — sandbox sends route to the mock provider and are never metered.
- Overage metering to Stripe — wired (add-on subscription items live-verified;
overage reported via the Billing Meter, delta-tracked). Activation per plan needs
the overage prices recreated as recurring metered + their meter
event_names set (see ROADMAP "Blocked on you"). High-volume paid sends bill rather than block (by design). Minor: the AI-credit check is read-then-record— fixed 2026-07-02: metering is atomic (tryConsumeAiCredit, single conditional UPDATE), reconciled post-run.- A dedicated dependency/secret-scanning step in CI.
- Full accessibility/UX audit (workstream 2.3).
Everything above is the technical evidence base, mapped roughly to the Trust
Services Criteria: access control (CC6) = the Authentication/Authorization
sections + staff RBAC + audited, time-boxed impersonation; change management
(CC8) = CI-built images deployed by immutable :sha tags, versioned migrations,
append-only message + staff audit logs; operations (CC7) = /health gating
deploys, rate limits and abuse gates, suppression-before-send, CAN-SPAM footer +
RFC 8058 one-click unsubscribe; confidentiality = TLS everywhere, VPC-only data
stores, hash-only credentials, HMAC-signed links, Ed25519-signed exports,
retention redact/delete, recorded per-org data region (organizations.data_region).
Sub-processors: AWS (SES, RDS, ElastiCache, EC2), Stripe, Anthropic, GitHub, Docker Hub.
What remains is organizational, not code (owner actions):
- Written policies — InfoSec, access review, incident response, vendor management, BC/DR (the controls exist; the documents don't).
- Recurring access reviews (staff accounts, AWS IAM, GitHub, Stripe) with sign-off records.
- A documented, tested RDS restore drill (automated backups are on).
- An external penetration test + remediation log.
- Pick an auditor / readiness platform (Vanta, Drata, Secureframe all cover this AWS + GitHub + Stripe stack), run the Type I window, then Type II.