Skip to content

Security controls — ZevSend

The high-level security posture. This page covers the technical controls; people-and-process controls (access reviews, on-call rotation, incident response) sit with the platform's security architecture and incident response pages.

Encryption at rest

Data Mechanism
Database storage (all of ZevSend's tables) Neon-on-AWS encrypts the underlying storage volumes
API keys Stored as SHA-256 hashes; plaintext shown once at creation, never persisted
OTP codes (Verify channel) Stored as hashes only — plaintext one-time code is never persisted
Webhook signing secrets Stored at rest in the database; only the customer ever needs the secret on their endpoint side, ZevSend uses it to sign outbound deliveries
ZPIP tokens (received from ZevID) Short-lived, not durably stored; verified per request

Encryption in transit

  • All inbound traffic terminates at the CDN edge with TLS 1.2+
  • Edge → origin (Coolify host on Hetzner Ashburn) is TLS-encrypted
  • Backend → external vendors (Neon, AWS SES, AWS SNS, ZevPay, SMS carriers, Meta Cloud API) is TLS by default
  • Internal Zev-product calls (ZevID, ZevCloud, ZevPay) all run over TLS

Authentication + authorisation

  • Customer auth — ZevID OAuth. ZevSend never holds customer passwords. MFA enforcement is ZevID's responsibility.
  • Admin auth — same ZevID OAuth flow, additionally gated by a server-side RBAC permission list. Permissions enumerate at admin-permissions.ts. Every admin endpoint declares its required permission via the @RequireAdminPermission(...) decorator.
  • Admin MFA — sensitive admin mutations (plan change, invoice mark-paid, MFA-gated decorators on the admin module) additionally require the x-admin-mfa-token header, verified against ZevID before the resolver runs.
  • Programmatic (API key) auth — per-key scope check. Hashed keys. Env separation (test / live) is server-enforced at key creation: the setup wizard can only mint test keys (and only when the team has zero active keys), the standalone /api-keys page can only mint live keys (and only when the team has at least one admin-approved domain). The two paths cannot produce the wrong env even if the client tampers with the request.
  • Domain-scoped keys — a customer pinning a key to one of their domains means sends from that key are refused for any other team domain, capping the blast radius of a leaked key in multi-brand teams.
  • Cross-product (ZPIP) auth — service+user tokens issued by ZevID with per-scope user consent. Inbound ZPIP endpoints are host-isolated.

Rate limiting

  • Global ThrottlerGuard wired as APP_GUARD so every controller inherits the baseline cap.
  • Public REST endpoints carry tighter per-route caps:
  • /v1/emails, /v1/sms, /v1/whatsapp — 60 req / 10s per IP
  • /v1/verify — 20 req / 10s per IP (tighter, against OTP enumeration and SIM-pumping)
  • Auth-side throttling on the GraphQL surface: same ThrottlerGuard (GraphQL-aware variant) protects resolver calls.

Tenant isolation

  • Every customer-resource row is scoped by team_id at the query layer. The team-membership check runs in middleware before the controller / resolver runs.
  • Domain ownership is double-checked at send time: the from-address's domain portion is resolved to a row, the row's teamId compared to the request's teamId, and the row's adminReviewStatus / verificationStatus checked before the carrier is ever called.
  • Sandbox teams cannot dispatch to arbitrary recipients — only to addresses on the team's verified_recipients allowlist.

Platform self-protection

  • API key env separation — the setup-wizard path (source: 'onboarding') is honoured only when the team has zero active keys, so a leaked client can't reuse it to mint additional test keys. The /api-keys path (source: 'console') is gated on an admin-approved domain existing, so a sandbox team can't accidentally mint live keys.
  • Brand-identity lock — once a domain's brand is admin-approved, {{brand.*}} template variables are server-injected from the locked record. The send-API payload cannot override them, so a leaked key can't impersonate a different brand on a verified domain.
  • Display-name approval gate — sends that pass a display string not registered + approved on the from-domain are refused before dispatch.
  • Send-time domain health check — sends from a domain currently in failed health state are refused; temporary_failure sends proceed (the 48-hour grace window protects customer traffic during transient DNS hiccups).
  • GraphQL depth limitdepthLimitRule(10) rejects deeply-nested queries before the resolvers run.
  • Webhook dispatch isolation — outbound webhook fan-out runs off the request path (setImmediate), so a slow customer endpoint cannot slow a send.

Cron concurrency

Each scheduled job carries an in-flight guard (class-level running boolean) so a slow sweep cannot overlap the next tick:

  • DomainHealthService (every 5 minutes) — domain DNS re-verification.
  • VerifyJanitorService (every 5 minutes) — stale pending verification cleanup.
  • BillingRenewalService (daily at 02:00 Africa/Lagos) — subscription renewal invoice generation.

Audit logging

  • Admin actions against customer resources (domain approve / reject, display-name approve / reject, sender-id approve / reject, plan change, suspend / reinstate, mark invoice paid) write to admin_actions (actor admin id, target resource type + id + label, metadata).
  • Inbound ZPIP requests + outbound ZPIP webhook deliveries are recorded for replay-protection + audit.
  • Customer-facing security-event logging (login, MFA) is owned by ZevID — see ZevID's section.

Secret handling

  • All secrets are passed via environment variables; nothing is checked in. .env.example lives in the repo as a key catalogue (no values).
  • Production-mode boot enforces explicit encryption keys; if either is missing, the backend refuses to start.
  • Webhook signing secrets are minted on subscription creation; ZevSend signs outbound deliveries with HMAC-SHA256 so customer endpoints can verify origin.

What's not yet in place

Honest about gaps:

  • No automated retention purge cron today across the send-log tables. Data is retained for the lifetime of the team / message row. See retention.md.
  • No formal pen-test schedule. Internal review + targeted ultrareview passes on security-sensitive surface today. The DPO will work with engineering on a formal schedule when needed.
  • No SIEM beyond the centralised log monitoring. Detection-and-response is reactive against logs + exception events.
  • No customer-visible data-export self-service yet on the dashboard. Subject-rights requests for export are handled manually by the DPO + engineering (see subject-rights.md).

These gaps are expected at our current stage; they're listed so the DPO knows what to ask about as the company grows.