Every integration is a new way into your system and a new key that can leak. The baseline: a separate least-privilege key per integration and environment, planned rotation with an overlap between the old and the new key, checking the signature, freshness and schema of every incoming event, requesting user-supplied URLs only after the destination address is checked (SSRF), and logs without tokens or personal data. Partner data gets the same strict validation as user input.
After this you will be able to
- build a threat model for an integration and name the control for each threat: key, permissions, signature, schema, SSRF filter, log
- plan an integration key rotation with an overlap, so the partner never gets rejected requests
- accept a partner event in the right order: size, signature, freshness,
zodschema, duplicates - protect an outgoing request to a user-supplied URL from SSRF: scheme, port, IP after DNS, no redirects, timeout and size limit
Know beforehand
Verified with
Node.js 24 LTS (24.18)zod 4.6OWASP API Security Top 10 (2023)
Checked against docs: October 11, 2026
Contents
The integration threat model
An integration is any data exchange with an external system: payments, a CRM, an ERP, a mailing service, importing an avatar from a link, a marketplace webhook. Each has three points where things can go wrong: the key you use to call the partner; the incoming data the partner sends you; and the outgoing requests your server makes to someone's address. Add a fourth: the logs where all of this gets written.
OWASP gives this its own entry in the API Security Top 10 (2023) — API10, «Unsafe Consumption of APIs»: developers tend to trust data from third-party APIs more than user input and weaken their checks. It is easier for an attacker to compromise a weak partner and send harmful data through it than to attack you directly. The same list has SSRF as API7: the server fetches a URL a user supplied.
Leaked key
- Example
- A CRM key in the repository, a log or the frontend
- Control
- A secrets store, separate keys, rotation, masking in logs
Overly broad permissions
- Example
- An «admin» key used to export one report
- Control
- Minimal scope, a separate service account per integration
Forged incoming event
- Example
- Someone posts «order paid» to your webhook URL
- Control
- HMAC signature, timestamp, mTLS or IP allowlist
Harmful partner data
- Example
- Extra fields, SQL in a string, a huge response, a redirect to another server
- Control
- Strict schema, size and time limits, no redirects
SSRF
- Example
- A user sets the webhook URL to
http://169.254.169.254/… - Control
- Scheme, port and post-DNS IP checks, network isolation
Leaks through logs
- Example
- A token, the customer's phone and email in a log line
- Control
- Structured logging with secrets removed and data masked
- 01Partner
- 02Event receiver
- 03Your system
- 04Outgoing client
- 05Foreign URL
Keys: storage, rotation, separate keys
Where to keep secrets, how to keep them out of git and the bundle, and what to do after a leak is covered in detail in the secrets and environment variables lesson. Integrations add three rules on top.
- A separate key per integration. The OWASP Secrets Management Cheat Sheet notes that when services share one secret, the source of a leak is hard to identify. A CRM key for the website and a CRM key for the background worker are two keys: one leaks — you revoke one.
- Separate keys per environment. The staging stand uses the partner's sandbox key, production uses its own. The production key never lands on developer machines or in test CI jobs.
- An owner and an expiry. For every key, record who issued it, where it is used, when it expires and when it was last rotated. OWASP recommends logging the creation, use, rotation and deletion of secrets.
Rotation is a planned replacement of a key so that a stolen copy works only for a short time. OWASP advises automating it, or at least documenting the process, because manual replacement often breaks the integration. To avoid downtime, keys are swapped with an overlap — for a while both are accepted:
Issue a new key
A second valid key appears at the partner or in your store. The old one still works.
Accept both
The receiver checks the signature with the new key first, then the old one — that is what
acceptPartnerEventbelow does with the[current, previous]array.Switch the sender
Your outgoing client and the partner start signing with the new key. Monitoring shows which key recent requests used.
Revoke the old key
The old key is removed from configuration and revoked at the partner. The test below shows that a signature with the old key then gets 401.
Least privilege, mTLS and IP allowlists
Least privilege for an integration means each connection has its own service account or OAuth client with exactly the rights it needs. The report export worker can only read reports; the website lead form can only create a lead, with no read access to the customer base. If the partner supports OAuth 2.0, use client credentials with a narrow scope; the code is in the OAuth 2.0 and OpenID Connect lesson.
Check permissions in the other direction too: what the partner can do with your API. A partner key must not open admin endpoints, and actions on objects are authorized just as they are for users — in the API Security Top 10 these are API1 and API5, broken object and function level authorization.
HMAC signature
- What it gives you
- Proves the body was sent by the holder of the shared secret and was not modified
- When it fits
- Almost always for webhooks and events
- Limitations
- Both sides hold the shared secret; replay protection is still needed
mTLS (mutual TLS)
- What it gives you
- The client presents its own certificate, verified during the handshake
- When it fits
- Banks, B2B exchange, internal service-to-service; when the partner supports it
- Limitations
- Certificate issuance and rotation, proxy configuration
IP allowlist
- What it gives you
- Drops requests not coming from the partner's addresses before they reach the app
- When it fits
- The partner publishes a stable address list
- Limitations
- Does not replace signatures: cloud addresses are shared and change; the list needs upkeep
OAuth client credentials
- What it gives you
- A short-lived narrow-scope token instead of a permanent key
- When it fits
- The partner acts as an authorization server
- Limitations
- The client secret still has to be stored and rotated
Incoming data: signature, freshness, schema, size
An incoming event is checked as a chain, and the order matters: cheap checks before expensive ones, the signature before JSON parsing, business logic only after the schema. The webhooks lesson covers HMAC signatures, redelivery and idempotency in depth; the focus here is on what comes after the signature: it proves the partner sent the event, not that its content is safe.
Size
A body over the limit is rejected with 413 before any work: huge JSON is an easy way to eat memory and CPU (API4 — unrestricted resource consumption).
Signature
HMAC-SHA256 over the timestamp and the raw body, compared in constant time with
timingSafeEqual. Both the current and the previous key are accepted — for rotation.Freshness
The timestamp is no older than five minutes: an intercepted request cannot be replayed tomorrow.
Schema
A strict schema: exact types, formats, ranges, allowed values, no extra fields. A schema failure returns 422 with the list of fields.
Duplicates
An event with an already processed
idis not executed again. In production — a unique key in a table, not an in-memorySet.
mkdir integration-security && cd integration-security
npm init -y && npm pkg set type=module
npm install zod@4.6.6zod is the only dependency// Incoming event from a partner: size -> signature -> freshness -> schema -> duplicates.
// The signature is HMAC-SHA256 over "<timestamp>.<raw body>" with a shared secret.
import { createHmac, timingSafeEqual } from "node:crypto";
import * as z from "zod";
const MAX_BODY_BYTES = 64 * 1024;
const TOLERANCE_SECONDS = 300;
const OrderPaid = z.strictObject({
id: z.string().regex(/^evt_[A-Za-z0-9]{8,32}$/),
type: z.literal("order.paid"),
data: z.strictObject({
orderId: z.string().regex(/^[0-9]{1,12}$/),
amount: z.number().int().positive().max(100_000_000),
currency: z.enum(["KZT", "USD"]),
}),
});
export class RejectedEvent extends Error {
name = "RejectedEvent";
constructor(status, reason) {
super(reason);
this.status = status;
}
}
export function sign(secret, timestamp, rawBody) {
return createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
}
function signatureMatches(secret, timestamp, rawBody, received) {
const expected = Buffer.from(sign(secret, timestamp, rawBody), "hex");
const got = Buffer.from(received, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}
const seen = new Set(); // in production: a table with a unique key on the event id
// secrets: [current, previous] — both are accepted while a key is being rotated
export function acceptPartnerEvent(rawBody, headers, secrets, now = Date.now()) {
if (rawBody.length > MAX_BODY_BYTES) throw new RejectedEvent(413, "body too large");
const timestamp = headers["x-partner-timestamp"] ?? "";
const signature = headers["x-partner-signature"] ?? "";
if (!/^\d{10}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) {
throw new RejectedEvent(401, "missing or malformed signature headers");
}
if (!secrets.filter(Boolean).some((s) => signatureMatches(s, timestamp, rawBody, signature))) {
throw new RejectedEvent(401, "bad signature");
}
if (Math.abs(now / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
throw new RejectedEvent(401, "stale timestamp");
}
let json;
try {
json = JSON.parse(rawBody.toString("utf8"));
} catch {
throw new RejectedEvent(400, "invalid JSON");
}
const parsed = OrderPaid.safeParse(json);
if (!parsed.success) {
throw new RejectedEvent(422, parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "));
}
if (seen.has(parsed.data.id)) return { duplicate: true, event: parsed.data };
seen.add(parsed.data.id);
return { duplicate: false, event: parsed.data };
}Why strictObject and not a plain object: an unknown isAdmin field in an event is either a partner bug or a mass assignment attempt if the data is later passed to an ORM as a whole. A strict schema rejects such an event instead of silently copying the field into the database. The orderId regex allows only digits, so a string with SQL never reaches a query — though parameterized queries are still mandatory.
$ node --test partner-event.test.mjs
first: false { orderId: '1042', amount: 15000, currency: 'KZT' } | repeat: true
after rotation: 401 bad signature
401 bad signature
401 missing or malformed signature headers
401 stale timestamp
422 data: Unrecognized key: "isAdmin"
422 data.orderId: Invalid string: must match pattern /^[0-9]{1,12}$/; data.amount: Too small: expected number to be >0; data.currency: Invalid option: expected one of "KZT"|"USD"
413 body too large
✔ valid event is accepted once, the repeat is marked as duplicate (2.769ms)
✔ an event signed with the previous key passes during rotation (0.452ms)
✔ tampering, stale timestamps, junk and oversized bodies are rejected (0.8696ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0node --test output on Node.js 24.18: the duplicate is detected, the old key is accepted only until rotation ends, a changed amount, a stale timestamp, an extra field, wrong formats and an oversized body are all rejectedOutgoing requests: SSRF and allowlists
SSRF (server-side request forgery) happens when the server requests an address chosen by a user: importing an avatar from a link, a customer's webhook URL, a link preview. The attacker supplies an internal address — a database, an admin panel, the cloud metadata service at 169.254.169.254 with its temporary keys — and the server fetches the response from the closed network for them.
The OWASP cheat sheet distinguishes two cases. If the destinations are known in advance — a payment gateway, a CRM, a partner API — use an allowlist: the user picks from a list and the server builds the URL; an example check is in the OWASP Top 10 lesson. If the address really is arbitrary, as with customer webhooks, what remains is a block list of internal networks, and getting it right is harder than it looks:
- Parse the URL with one standard parser:
https://0x7f000001/andhttps://2130706433/both parse to127.0.0.1. Check the result ofnew URL, not the raw string. - A domain name can point to an internal IP. Check every address it resolves to — A and AAAA.
- The check and the connection must use the same DNS result. If you check the name and then let the HTTP client resolve it again, the attacker can switch the record in between (DNS rebinding). That is why the check lives inside the connection's own
lookup. - Do not follow redirects: an allowed external server can answer 302 to an internal address. API10 has the same requirement: never follow a partner's redirects blindly.
- A timeout and a response size limit are mandatory: a slow or endless response ties up your server's connections.
// Outgoing request to a URL supplied by a user (avatar import, customer webhook URL).
// SSRF defences from the OWASP cheat sheet: scheme allowlist, IP check after DNS,
// connection only to the checked address, no redirects, timeout and size limit.
import { lookup as dnsLookup } from "node:dns";
import { request } from "node:https";
import { BlockList, isIP } from "node:net";
const denied = new BlockList();
for (const [net, prefix] of [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
]) denied.addSubnet(net, prefix, "ipv4");
for (const [net, prefix] of [
["::", 128], ["::1", 128], ["64:ff9b::", 96], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
]) denied.addSubnet(net, prefix, "ipv6");
export function isPublicAddress(ip) {
const family = isIP(ip);
if (family === 0) return false;
// BlockList also matches IPv4-mapped IPv6 (::ffff:127.0.0.1) against IPv4 rules
return !denied.check(ip, family === 4 ? "ipv4" : "ipv6");
}
export class BlockedUrlError extends Error {
name = "BlockedUrlError";
}
// Every address the name resolves to must be allowed; the socket then connects
// to exactly these addresses, so there is no second, unchecked DNS lookup.
function guardedLookup(isAllowed) {
return (hostname, options, callback) => {
dnsLookup(hostname, { all: true }, (err, addresses) => {
if (err) return callback(err);
const bad = addresses.find((a) => !isAllowed(a.address));
if (bad) return callback(new BlockedUrlError(`${hostname} resolves to a blocked address ${bad.address}`));
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
};
}
export function safeGet(input, { timeoutMs = 5000, maxBytes = 1_000_000, isAllowed = isPublicAddress, ports = ["", "443"] } = {}) {
let url;
try {
url = new URL(input);
} catch {
return Promise.reject(new BlockedUrlError("invalid URL"));
}
if (url.protocol !== "https:") return Promise.reject(new BlockedUrlError(`scheme ${url.protocol} is not allowed`));
if (url.username || url.password) return Promise.reject(new BlockedUrlError("credentials in URL are not allowed"));
if (!ports.includes(url.port)) return Promise.reject(new BlockedUrlError(`port ${url.port} is not allowed`));
const host = url.hostname.replace(/^\[|\]$/g, "");
// An IP literal skips DNS, so it is checked here
if (isIP(host) && !isAllowed(host)) return Promise.reject(new BlockedUrlError(`address ${host} is blocked`));
return new Promise((resolve, reject) => {
const req = request(url, {
method: "GET",
lookup: guardedLookup(isAllowed),
signal: AbortSignal.timeout(timeoutMs),
headers: { "user-agent": "interlab-fetcher/1.0" },
}, (res) => {
// node:https does not follow redirects; we do not follow them either
if (res.statusCode >= 300 && res.statusCode < 400) {
res.resume();
return reject(new BlockedUrlError(`redirect to ${res.headers.location} was not followed`));
}
const chunks = [];
let size = 0;
res.on("data", (chunk) => {
size += chunk.length;
if (size > maxBytes) {
req.destroy(new BlockedUrlError(`response is larger than ${maxBytes} bytes`));
return;
}
chunks.push(chunk);
});
res.on("end", () => resolve({ status: res.statusCode, type: res.headers["content-type"], body: Buffer.concat(chunks) }));
res.on("error", reject);
});
req.on("error", reject);
req.end();
});
}$ node --test safe-fetch.test.mjs
http://example.com/ -> scheme http: is not allowed
file:///etc/passwd -> scheme file: is not allowed
https://user:pass@example.com/ -> credentials in URL are not allowed
https://example.com:8443/ -> port 8443 is not allowed
https://169.254.169.254/latest/meta-data/ -> address 169.254.169.254 is blocked
https://0x7f000001/ -> address 127.0.0.1 is blocked
https://2130706433/admin -> address 127.0.0.1 is blocked
https://[::ffff:127.0.0.1]/ -> address ::ffff:7f00:1 is blocked
✔ isPublicAddress: private, loopback, link-local and mapped addresses are blocked (5.2406ms)
https://localhost/ -> localhost resolves to a blocked address ::1
✔ dangerous URLs are rejected before any connection (10.0721ms)
https://example.com/ -> 200 text/html; charset=utf-8, 577 bytes
✔ a public HTTPS address works (42.96ms)
/redirect -> BlockedUrlError: redirect to http://169.254.169.254/latest/meta-data/ was not followed
/big -> BlockedUrlError: response is larger than 1000000 bytes
/slow -> AbortError: The operation was aborted
✔ redirects are not followed, size and time are limited (529.0931ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0localhost after DNS, a public address works. To test redirects, size and timeout, the test explicitly allows a local HTTPS server with a self-signed certificateLogs without secrets or personal data
Integrations are debugged through logs, and that is where keys leak most often: the Authorization header, ?api_key= in a URL, a partner request stored whole. The OWASP Logging Cheat Sheet lists what should not be logged directly: access tokens, session identifiers, encryption keys and other primary secrets, sensitive personal data; non-sensitive personal data — names, phone numbers, emails — should be masked or pseudonymized where possible. It also requires stripping line breaks so a foreign value cannot forge a new log entry.
// Integration log: JSON lines, secrets removed, personal data masked.
const SECRET_KEYS = /authorization|cookie|token|secret|password|api[-_]?key|signature/i;
// Query strings often carry keys (?api_key=…): keep only the origin and path
function stripQuery(value) {
const url = new URL(value);
return url.search ? `${url.origin}${url.pathname}?[REDACTED]` : value;
}
function maskEmail(value) {
const [user, domain] = value.split("@");
return `${user.slice(0, 1)}***@${domain}`;
}
function maskPhone(value) {
const digits = value.replace(/\D/g, "");
return `***${digits.slice(-2)}`;
}
export function redact(value, key = "") {
if (SECRET_KEYS.test(key)) return "[REDACTED]";
if (Array.isArray(value)) return value.map((v) => redact(v));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)]));
}
if (typeof value === "string" && /^https?:\/\//.test(value)) return stripQuery(value);
if (typeof value === "string" && /^[^@\s]+@[^@\s]+$/.test(value)) return maskEmail(value);
if (typeof value === "string" && /^phone$/i.test(key)) return maskPhone(value);
if (typeof value === "string" && /^(name|full_?name)$/i.test(key)) return `${value.slice(0, 1)}***`;
return value;
}
export function logIntegration(event, fields) {
// JSON.stringify escapes \r and \n, so a partner's value cannot forge a new log line
console.log(JSON.stringify({ time: new Date().toISOString(), event, ...redact(fields) }));
}import { logIntegration } from "./safe-log.mjs";
logIntegration("crm.lead.create", {
integration: "crm-main",
status: 401,
durationMs: 184,
request: {
url: "https://crm.example.test/api/v4/leads?api_key=YOUR_API_KEY",
headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json" },
},
lead: { name: "Test Client", phone: "+7 701 000 00 42", email: "client@example.test" },
partnerMessage: "invalid token\nINFO payment ok",
});$ node safe-log.demo.mjs
{"time":"2026-10-12T03:00:27.507Z","event":"crm.lead.create","integration":"crm-main","status":401,"durationMs":184,"request":{"url":"https://crm.example.test/api/v4/leads?[REDACTED]","headers":{"Authorization":"[REDACTED]","Content-Type":"application/json"}},"lead":{"name":"T***","phone":"***42","email":"c***@example.test"},"partnerMessage":"invalid token\nINFO payment ok"}Such a filter is a safety net, not the main defence. It is more reliable not to pass extra data to the logger in the first place: log the event id, the integration, the status, the duration and the partner's error code, not the whole body. In our experience that is enough to investigate most incidents; full request bodies, when really needed, are stored separately with short retention and restricted access.
Checklist before launching an integration
- The integration key is issued specifically for this integration and this environment and lives in the hosting secrets or a secrets manager, not in code
- The key or service account has minimal permissions; you have checked it does not open extra methods
- The key owner, expiry and rotation procedure are known; the receiver can accept two keys during a swap
- Every incoming event goes through the chain: size → signature → freshness → strict schema → duplicates
- For B2B exchange where the partner supports mTLS or has stable addresses, mTLS or an IP allowlist is enabled as an extra layer
- Partner data gets the same validation as user input and reaches the database only through parameterized queries
- Outgoing requests: TLS, timeout, size limit, no blind redirect following
- Requests to user-supplied URLs pass the SSRF filter, and the service is isolated from the internal network at the network level
- Logs contain no tokens, keys, query strings with keys or plain personal data; there is an alert on a spike of 401s and signature errors
- There is a leak plan: who revokes the key, how fast a new one is issued, how the partner is notified
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.