Reference
Security
What is protected, and how
| Secret | At rest | In transit | Exposure |
|---|---|---|---|
| User password | scrypt (N=16384, r=8, p=1), 16-byte random salt | HTTPS | Never returned. |
| API key | SHA-256 hash + a 14-char display prefix | HTTPS | Shown once at creation. |
| DKIM private key | AES-256-GCM under DKIM_PRIVATE_KEY_ENCRYPTION_KEY | Never leaves the process | Never in an API response; not a field on the serialiser. |
| Webhook signing secret | AES-256-GCM | HTTPS | Shown once at creation. |
| Session token | Not stored — stateless JWT | HTTPS | 12-hour expiry. |
| Database / Redis credentials | .env, chmod 600, git-ignored | Compose network only | Never 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:
subjectrejects any string containing\ror\n.parseFromAddressrejects a display name containing CRLF.- Custom
headersmust 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
| Layer | Scope | Default |
|---|---|---|
| nginx | per IP | 30 r/s, burst 60 |
| Fastify | per IP | 600/minute |
| Fastify | per IP, credential endpoints | 10 per 15 minutes |
| Application | per user, domain, API key | 100/hour, 1000/day |
| Application | new accounts, first 24h | 20/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. /internaland/metrics— 404 at nginx, and the API additionally requiresX-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
| Secret | Effect of rotating | Procedure |
|---|---|---|
JWT_SECRET | All sessions invalidated; users log in again. | Change and restart. |
INTERNAL_API_TOKEN | Bounce pipe breaks until Postfix restarts. | Change, then restart api and postfix together. |
WEBHOOK_SIGNING_SECRET | None — per-webhook secrets are independent. | Change and restart. |
DKIM_PRIVATE_KEY_ENCRYPTION_KEY | All signing breaks unless rows are re-encrypted. | Decrypt with old, re-encrypt with new, then deploy. |
| API key | That 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.