v2.0.0

Security for event-receiver apps

Two hardening patterns for any server that receives Bitrix24 outbound events or OAuth install/uninstall callbacks: reply 200 before you verify, and compare application_token in constant time.

Overview

This page is for servers that expose a public HTTP endpoint Bitrix24 POSTs to:

  • Outbound event receivers — an app that registers events with event.bind (ONCRMDEALADD, ONCRMDEALUPDATE, …) and processes them, as in the webhook handler recipe.
  • OAuth apps — a marketplace app that handles ONAPPINSTALL / ONAPPUPDATE / ONAPPUNINSTALL callbacks and stores per-portal credentials, as in the OAuth install recipe.

Both share the same threat model: the URL is reachable by anyone, and Bitrix24 authenticates each delivery with an application_token in the payload. Two patterns keep these endpoints correct and safe. They are independent of the SDK transport surface — they live in your HTTP handler — so they are documented here rather than on an action page.

Credential redaction inside the SDK is a separate concern — see Logging & Credential Redaction. This page is about the request-handling layer around the SDK.

Pattern 1 — Reply 200 first, verify after

Bitrix24 doesn't call your handler in real time — it queues events on a separate server and watches how fast your handler answers. A handler that runs verification or processing before replying answers slowly, and Bitrix24 starts delivering to it with lower priority (longer pauses between calls). And there is no automatic retry: if your handler errors or times out, Bitrix24 records the failure and never re-sends that event — "no second chance" (see the events overview). So a slow-or-throwing handler both degrades your delivery rate and silently drops events.

The fix is ordering: send 2xx first, then verify and process. The acknowledgement goes out fast (keeping you off the slow-handler penalty), and verification/processing no longer block it. If losing an event is unacceptable, don't rely on live delivery — use offline events as the durable channel.

import { type Request, type Response } from 'express'

// Stand-ins for your real verification / processing logic.
function verifyOrigin(req: Request): boolean { return Boolean(req.body?.auth) }
async function handleEvent(payload: unknown): Promise<void> { /* … */ }

// WRONG — verify + process run BEFORE the 200 reply.
async function onWebhookWrong(req: Request, res: Response) {
  if (!verifyOrigin(req)) return  // may be slow, may throw
  await handleEvent(req.body)     // if this throws, the next line never runs…
  res.sendStatus(200)             // …so the ack never goes out — the event is
                                  // lost (no retry) and the slow path gets you
                                  // deprioritized
}
import { type Request, type Response } from 'express'

function verifyOrigin(req: Request): boolean { return Boolean(req.body?.auth) }
async function handleEvent(payload: unknown): Promise<void> { /* … */ }

// CORRECT — acknowledge first, verify + process after.
async function onWebhook(req: Request, res: Response) {
  const payload = req.body

  res.sendStatus(200)             // 1. reply immediately — fast ack avoids the
                                  //    slow-handler penalty

  if (!verifyOrigin(req)) return  // 2. drop spoofed / stale events (already acked)
  await handleEvent(payload)      // 3. safe: Bitrix24 already has its 200
}

The webhook handler recipe replies with a small JSON acknowledgement — res.status(200).json({ status: 'ok' }) — which is equivalent to res.sendStatus(200); either works as long as it goes out first.

Pattern 2 — Verify application_token in constant time

Bitrix24 includes an application_token in the auth block of every event. It is the shared secret that proves the request really came from your portal — without checking it, anyone who learns the URL can replay arbitrary events.

Compare it with a constant-time function. A plain === (or !=) returns as soon as it hits the first differing byte, so an attacker can recover the token one character at a time by measuring response latency. Node's crypto.timingSafeEqual compares in constant time, but it throws when the two buffers differ in length — so pre-check the length first (which also avoids leaking length):

import { type Request, type Response } from 'express'
import { timingSafeEqual } from 'node:crypto'

// Constant-time compare. `timingSafeEqual` throws on a length mismatch, so we
// pre-check length first — this also avoids leaking length via the exception.
function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a, 'utf8')
  const bb = Buffer.from(b, 'utf8')
  if (ab.length !== bb.length) return false
  return timingSafeEqual(ab, bb)
}

const expectedApplicationToken = process.env.B24_APPLICATION_TOKEN ?? ''

// Called AFTER res.sendStatus(200) (Pattern 1). Returns false → drop the event.
function verifyEvent(req: Request, _res: Response): boolean {
  const incomingToken = req.body?.auth?.application_token ?? ''
  return safeEqual(incomingToken, expectedApplicationToken)
}

Store the expected token out of band (an env var such as B24_APPLICATION_TOKEN, sourced from the Bitrix24 dev console → Local Application → application_token) and fail closed when it is unset.

OAuth uninstall: verify before deleting

The same rule is critical on the uninstall callback. ONAPPUNINSTALL deletes a portal's stored credentials — so if you delete without verifying, anyone who can reach /uninstall can wipe a portal's tokens just by guessing its member_id. Always compare the incoming application_token against the one you recorded at install time, in constant time, before removing anything:

import { type Request, type Response } from 'express'

// `safeEqual` is the constant-time helper from Pattern 2 above.
declare function safeEqual(a: string, b: string): boolean
declare function getCredentials(memberId: string): Promise<{ applicationToken: string } | null>
declare function deleteCredentials(memberId: string): Promise<void>

async function handleUninstall(req: Request, res: Response) {
  res.sendStatus(200)  // reply first — Pattern 1 applies to uninstall too

  const memberId = req.body?.auth?.member_id ?? ''
  const receivedToken = req.body?.auth?.application_token ?? ''
  if (!memberId || !receivedToken) return

  const stored = await getCredentials(memberId)
  if (!stored) return  // idempotent — nothing to delete

  // ALWAYS verify application_token before deleting. Without this check, anyone
  // who reaches /uninstall could wipe a portal's tokens by guessing member_id.
  if (!safeEqual(stored.applicationToken, receivedToken)) return

  await deleteCredentials(memberId)
}

Checklist

Every event-receiver endpoint (event.bind handlers):

  • Serve the handler over HTTPS at a publicly reachable URL — Bitrix24 calls it from its own servers, and the application_token travels in the request body, so plain HTTP would expose it in transit.
  • Reply 2xx before verifying or processing (Pattern 1).
  • Require an application_token env var and fail closed if it is unset.
  • Compare the incoming application_token with safeEqual (Pattern 2), never ===.
  • Drop — do not error — on a mismatch (return the 2xx you already sent; don't reveal which check failed).
  • Never log the application_token (or the raw auth block). Log a decision, not the secret.

OAuth apps (install / uninstall callbacks), additionally:

  • Persist application_token alongside the tokens at install time.
  • On uninstall, verify application_token before deleting credentials.
  • Treat a missing record as idempotent success (no error — it is already gone).
  • Store tokens with restrictive permissions (file mode 0o600 or a datastore with per-tenant isolation).

See also