Security

A Sent integration touches two credentials — the caller's API key and the webhook signing secret — and exposes at least one public, state-mutating endpoint. Get the handling of those two secrets right, verify every inbound request, and validate everything at the edge. The rest is defense in depth.

Nothing here is Sent-specific magic — it's standard backend hygiene applied to the two secrets and the one public endpoint this integration adds.

Secrets: hold them for the shortest possible time

Two rules cover almost every mistake we see.

The API key is a request credential, not config. Build the SDK client per request from the incoming Authorization: Bearer <key> header, use it, and throw the client away. Never persist the key, never log it, never bake it into a boot-time singleton or a SENT_DM_API_KEY env var. If there's no bearer token, return 401 — see Authentication. (Queued/async work is the one scoped exception, with its own hygiene rules — see Errors & resilience.)

The webhook signing secret never leaves memory and never gets logged. It enters the process the moment a customer registers or rotates an endpoint, lives in a store keyed for verification, and is used only to compute HMACs. It is never written to logs, error messages, or responses.

The single most common leak is an over-eager logger. logger.info(req.headers) or logger.debug(req.body) on a webhook route will happily print the signing secret material, the raw signature, and recipient phone numbers. Log identifiers, not payloads — see Observability.

HTTPS only

Sent delivers webhooks over HTTPS and expects your registered endpoint to be HTTPS. In production:

  • Terminate TLS at your load balancer or gateway; redirect any http:// to https://.
  • Register only https:// webhook URLs. A plaintext endpoint leaks the raw body (recipient numbers, message content) and the signature on the wire.
  • Set HSTS. helmet() (below) does this and a dozen other headers for you.

Security headers and CORS

Wire helmet() for sane default headers and a cors policy that is strict in production and permissive only for your local dev origins.

// src/app.ts
app.use(helmet());
app.use(
  cors({
    origin:
      env.NODE_ENV === 'production'
        ? [/\.your-domain\.com$/] // allow-list your own front-ends; no wildcard
        : ['http://localhost:3000', 'http://localhost:5173'],
    credentials: true,
  }),
);
# src/app/main.py
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "http://localhost:5173"],  # tighten in prod
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

CORS protects your browser clients, not your webhook receiver. Sent's servers don't send an Origin header and aren't subject to CORS at all — the receiver's protection is signature verification, not CORS. Never rely on an origin allow-list to secure /webhooks/sent.

Validate at the edge

Parse and validate every request body before it reaches your service layer, and reject with a 400 on failure. Use a schema validator — zod in TypeScript, pydantic in Python — so malformed input never becomes a malformed SDK call.

import { z } from 'zod';

const SendSchema = z.object({
  to: z.array(z.string().min(3)).min(1),
  channel: z.array(z.enum(['sms', 'whatsapp', 'rcs'])).optional(),
  template: z.object({
    id: z.string().uuid().optional(),
    name: z.string().optional(),
    parameters: z.record(z.string()).optional(),
  }),
  sandbox: z.boolean().optional(),
});

const parsed = SendSchema.safeParse(req.body);
if (!parsed.success) {
  return res.status(400).json({
    error: { code: 'ValidationError', message: 'Invalid request body' },
  });
}
from pydantic import BaseModel, Field

class Template(BaseModel):
    id: str | None = None
    name: str | None = None
    parameters: dict[str, str] | None = None

class SendRequest(BaseModel):
    to: list[str] = Field(min_length=1)
    channel: list[str] | None = None
    template: Template
    sandbox: bool | None = None

# FastAPI validates the body against SendRequest and returns 422 on failure.

For the webhook receiver, validate the signature on the raw body first, then parse JSON. Parsing before verifying hands attacker-controlled input to your JSON parser and, worse, re-serializes the body so the bytes you sign no longer match what Sent signed. See Signature verification.

Rate limiting

Rate-limit your public surface: apply a global limiter to everything and a stricter, separate limiter to the webhook receiver — the receiver is a public URL and should tolerate Sent's legitimate burst while shedding abuse.

// src/app.ts — global limiter
const limiter = rateLimit({
  windowMs: env.RATE_LIMIT_WINDOW_MS,
  max: env.RATE_LIMIT_MAX_REQUESTS,
  standardHeaders: true,
  legacyHeaders: false,
  handler: (_req, res) =>
    res.status(429).json({
      error: { code: 'RateLimitExceeded', message: 'Too many requests' },
    }),
});
app.use(limiter);

// A tighter limiter just for the receiver. skipSuccessfulRequests means
// legitimate, verified deliveries don't count toward the cap — only the
// junk (unverified, malformed) does.
const webhookLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 60,
  skipSuccessfulRequests: true,
});
app.use('/webhooks', webhookLimiter, webhooksRouter);
# src/app/main.py
from slowapi import Limiter
from slowapi.middleware import SlowAPIMiddleware

limiter = Limiter(key_func=lambda request: "global")  # slowapi always passes the Request in
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)

# Apply a stricter per-route decorator on the receiver, e.g.
# @limiter.limit("60/minute")

In a multi-instance deployment, an in-memory rate-limit counter is per-instance, so your effective limit is max × instances. For a hard global cap, back the limiter with Redis (rate-limit-redis for express-rate-limit). See Scaling & deployment.

Never log secrets or PII

Treat these as never-log values, everywhere:

  • The API key and any Authorization header.
  • The webhook signing secret and the X-Webhook-Signature header.
  • Raw webhook and request bodies — they contain recipient phone numbers and message content.

Log identifiers and outcomes instead: request ID, message_id, event type, status code, duration. If you must log a phone number for support, mask it (+1******7890).

Replay protection (recap)

Signature verification proves a request is authentic; the timestamp window proves it's fresh. A captured, valid request can otherwise be replayed indefinitely. Enforce both:

  • Reject if |now − X-Webhook-Timestamp| > 300s (±5 minutes).
  • On rotation, the X-Webhook-Signature header may carry space-separated signatures — accept if any matches a current secret.
  • Verify against the raw body with a constant-time compare.

Full implementation and the exact HMAC scheme live in Signature verification.

Next steps

On this page