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://tohttps://. - 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
Authorizationheader. - The webhook signing secret and the
X-Webhook-Signatureheader. - 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-Signatureheader 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
Tracking Delivery Status
Close the loop — turn the stream of webhook events into a queryable per-message status, using a forward-only, idempotent store, and expose it as GET /api/messages/:id.
Observability
Structured logging, request-ID correlation, log levels, and metrics/tracing hooks for a Sent integration — with the secrets left out.