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-tokenheader, 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-keyspage 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
ThrottlerGuardwired asAPP_GUARDso 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_idat 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
teamIdcompared to the request'steamId, and the row'sadminReviewStatus/verificationStatuschecked before the carrier is ever called. - Sandbox teams cannot dispatch to arbitrary recipients — only to addresses on the team's
verified_recipientsallowlist.
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-keyspath (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
failedhealth state are refused;temporary_failuresends proceed (the 48-hour grace window protects customer traffic during transient DNS hiccups). - GraphQL depth limit —
depthLimitRule(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) — stalependingverification 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.examplelives 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.