Reference

Security

What is protected, and how

SecretAt restIn transitExposure
User passwordscrypt (N=16384, r=8, p=1), 16-byte random saltHTTPSNever returned.
API keySHA-256 hash + a 14-char display prefixHTTPSShown once at creation.
DKIM private keyAES-256-GCM under DKIM_PRIVATE_KEY_ENCRYPTION_KEYNever leaves the processNever in an API response; not a field on the serialiser.
Webhook signing secretAES-256-GCMHTTPSShown once at creation.
Session tokenNot stored — stateless JWTHTTPS12-hour expiry.
Database / Redis credentials.env, chmod 600, git-ignoredCompose network onlyNever in a response. Not published ports.

Passwords

scrypt from Node's standard library — memory-hard, no native build step, no third-party dependency in the authentication path. Verification is constant-time via timingSafeEqual.

Login runs a full scrypt comparison even when the account does not exist, against a hash of a value nobody knows. Without that, response timing would reveal which addresses are registered.

API keys

The token is 32 characters from a 62-character alphabet — about 190 bits. Since the secret has that much entropy, SHA-256 is the right hash: it is fast enough to look up on every request, and there is nothing to brute-force. (A slow hash is for low-entropy secrets like passwords.)

Only the hash and a display prefix are stored. lastUsedAt is written at most once a minute per key so authentication does not become a write on every request.

An API key cannot create another API key — key management requires a dashboard session. One leaked key therefore cannot be used to establish persistence.

DKIM private keys

Generated per domain, encrypted before the row is written, decrypted only inside the worker at signing time. dkimPrivateKeyEncrypted is in the logger's redact list and is absent from serialiseDomain(), which is the only function that turns a domain into an API response. Tests assert that neither the string PRIVATE KEY nor the field name appears in any domain response.

See infrastructure/dkim/README.md for why signing happens in the worker rather than in OpenDKIM, and how to rotate.


Input handling

Everything is validated with Zod before it reaches business logic. Requests that fail return VALIDATION_ERROR with the offending paths.

Header injection

The most dangerous input in a mail API is anything that reaches a message header. Three layers:

  1. subject rejects any string containing \r or \n.
  2. parseFromAddress rejects a display name containing CRLF.
  3. Custom headers must match ^[A-Za-z0-9-]{1,78}$, must not contain CRLF in the value, and must not be one of the reserved headers (To, Cc, Bcc, From, Subject, Message-ID, Return-Path, DKIM-Signature, Received, Content-Type, MIME-Version, Content-Transfer-Encoding, Date).

Without the third rule a caller could set their own Bcc and turn the API into a blind relay.

SQL injection

Prisma parameterises everything. The one raw query in the codebase (the daily statistics rollup) uses Prisma.sql tagged templates, so userId and the date bound are parameters — no string interpolation reaches the SQL text.

XSS

The API returns JSON only, with X-Content-Type-Options: nosniff. The dashboard renders through React, which escapes by default; there is no dangerouslySetInnerHTML anywhere in it. Customer HTML email bodies are stored and delivered, never rendered in the dashboard.

CSRF

Not applicable, deliberately. Authentication is a bearer token in the Authorization header, never a cookie, so a cross-site form post carries no credentials. If you add cookie authentication, you must add CSRF tokens with it.

Request size

bodyLimit is EMAIL_MAX_SIZE_BYTES (10 MB default), enforced by Fastify before the body is parsed, and again by nginx (client_max_body_size 12m).


Multi-tenancy

Every customer-owned table has a userId column, and every query filters on it. Lookups use findFirst({ where: { id, userId } }) rather than findUnique({ where: { id } }) followed by a check — the ownership condition is part of the query, so there is no path where it can be forgotten.

Another tenant's resource returns 404, not 403. A 403 would confirm the resource exists.

tests/integration/multi-tenancy.test.ts asserts this across domains, emails, API keys, webhooks, suppressions and statistics, including that suppression lists do not leak between accounts.


Rate limiting and abuse

LayerScopeDefault
nginxper IP30 r/s, burst 60
Fastifyper IP600/minute
Fastifyper IP, credential endpoints10 per 15 minutes
Applicationper user, domain, API key100/hour, 1000/day
Applicationnew accounts, first 24h20/hour, 100/day

Message quota is consumed per recipient and checked atomically across all scopes with a Lua script, so a request that exceeds any limit increments none of them. A request rejected for any other reason refunds what it took.

Abuse signals (bounce rate, complaint rate, failure rate, volume spikes, recipient repetition) are evaluated out of band and written to the audit log. Automatic suspension is off by default — see operations.md.

Not an open relay

Postfix accepts submission only from mynetworks (the compose network), and smtpd_relay_restrictions ends in reject_unauth_destination. Port 25 is open inbound only so bounce reports can arrive; those are routed to a pipe that forwards them to the API and are never delivered to a mailbox.

The From domain must be verified on the sending account, which is what stops the API being used to spoof arbitrary domains.


Transport security

HTTPS everywhere, TLS 1.2+, HSTS with a one-year max-age in production.

Outbound SMTP uses smtp_tls_security_level = may — opportunistic. Requiring TLS on every hop would silently fail delivery to servers that do not offer STARTTLS. When it is offered, TLS 1.2+ is mandatory (smtp_tls_mandatory_protocols = >=TLSv1.2).

Security headers on every response: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY, Permissions-Policy denying camera/microphone/geolocation, and HSTS in production. CORS allows only DASHBOARD_URL in production.


What is never exposed

Enforced by the error handler, the serialisers and the logger's redact list:

  • SMTP credentials and MTA internals — including the Postfix queue ID, which is stripped from GET /v1/emails/{id}.
  • DKIM private keys, in any encoding.
  • Database and Redis connection strings.
  • API key hashes.
  • Stack traces, SQL text, or Prisma error internals. Anything unrecognised becomes {"error":{"code":"INTERNAL_ERROR","message":"An unexpected error occurred. The incident has been logged."}} with the real error logged server-side.
  • /internal and /metrics — 404 at nginx, and the API additionally requires X-Internal-Token.

Secrets management

All secrets come from environment variables. .env is git-ignored; .env.example contains placeholders only and is the file that is committed. .gitignore covers .env, .env.* (except the example), infrastructure/dkim/keys/, and all build output.

Generate each secret independently:

openssl rand -hex 32

Never reuse a secret across environments. DKIM_PRIVATE_KEY_ENCRYPTION_KEY in particular is not recoverable — losing it means every customer domain must republish DNS, and a database backup without it is useless.

Rotation

SecretEffect of rotatingProcedure
JWT_SECRETAll sessions invalidated; users log in again.Change and restart.
INTERNAL_API_TOKENBounce pipe breaks until Postfix restarts.Change, then restart api and postfix together.
WEBHOOK_SIGNING_SECRETNone — per-webhook secrets are independent.Change and restart.
DKIM_PRIVATE_KEY_ENCRYPTION_KEYAll signing breaks unless rows are re-encrypted.Decrypt with old, re-encrypt with new, then deploy.
API keyThat key stops working immediately.Revoke in the dashboard; create a replacement first.

Reporting a vulnerability

Email security@yourservice.com. Please include reproduction steps and give a reasonable window before public disclosure.


Scope

This platform is built for legitimate transactional and application email. It contains no functionality for evading spam filters, disguising sending identity, or bypassing recipient-provider policy — and adding any would be counter to how it works. Deliverability here comes from correct authentication, clean lists and a good reputation, which is the only approach that survives contact with a real mailbox provider.