How to buildOctober 11, 202611 min read
APIs and integrationsStep 6 of 8

Integration security: keys, signatures, least privilege

In short

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.

FundamentalsLevel: IntermediateIntegration Security

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, zod schema, duplicates
  • protect an outgoing request to a user-supplied URL from SSRF: scheme, port, IP after DNS, no redirects, timeout and size limit

Verified with

Node.js 24 LTS (24.18)zod 4.6OWASP API Security Top 10 (2023)

Checked against docs: October 11, 2026

Contents
  1. 01The integration threat model
  2. 02Keys: storage, rotation, separate keys
  3. 03Least privilege, mTLS and IP allowlists
  4. 04Incoming data: signature, freshness, schema, size
  5. 05Outgoing requests: SSRF and allowlists
  6. 06Logs without secrets or personal data
  7. 07Checklist before launching an integration

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
What can go wrong and which control covers it
  1. 01Partner
  2. 02Event receiver
  3. 03Your system
  4. 04Outgoing client
  5. 05Foreign URL
Incoming data and outgoing requests are two different trust boundaries, and both are checked

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:

  1. Issue a new key

    A second valid key appears at the partner or in your store. The old one still works.

  2. Accept both

    The receiver checks the signature with the new key first, then the old one — that is what acceptPartnerEvent below does with the [current, previous] array.

  3. Switch the sender

    Your outgoing client and the partner start signing with the new key. Monitoring shows which key recent requests used.

  4. 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.

  1. 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).

  2. 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.

  3. Freshness

    The timestamp is no older than five minutes: an intercepted request cannot be replayed tomorrow.

  4. Schema

    A strict schema: exact types, formats, ranges, allowed values, no extra fields. A schema failure returns 422 with the list of fields.

  5. Duplicates

    An event with an already processed id is not executed again. In production — a unique key in a table, not an in-memory Set.

bash
mkdir integration-security && cd integration-security
npm init -y && npm pkg set type=module
npm install zod@4.6.6
Project for the examples; zod is the only dependency
partner-event.mjs
// 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 };
}
Receiver for the «order paid» event: the schema covers formats, ranges and allowed currencies, not just types

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.

terminal
$ 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 0
Real node --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 rejected

Outgoing 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/ and https://2130706433/ both parse to 127.0.0.1. Check the result of new 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.
safe-fetch.mjs
// 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();
  });
}
A safe GET for a user-supplied URL with built-in Node.js modules. The ranges are the OWASP cheat sheet minimum plus CGNAT, special-purpose and NAT64 networks
terminal
$ 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 0
Real output: dangerous addresses rejected before connecting, localhost after DNS, a public address works. To test redirects, size and timeout, the test explicitly allows a local HTTPS server with a self-signed certificate

Logs 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.

safe-log.mjs
// 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) }));
}
A structured integration log: secret keys replaced, the query string removed, email, phone and name masked
safe-log.demo.mjs
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",
});
A record of a failed CRM request; the keys are placeholders
terminal
$ 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"}
Real output: the header and query string are hidden, contacts masked, and the line break from the partner's response stayed inside the JSON string instead of creating a forged entry

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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.