Skip to main content

Cross-organization threat sharing

An opt-in indicator pool on Enterprise: the five actions, the three-reporter floor, how every indicator is keyed before it is stored, and the tab.

Last reviewed October 19, 2026Fresh

Overview

Organizations on PasskeyBridge can pool indicators of compromise in one shared pool. An indicator surfaces in the feed only once several organizations have reported the same value independently, so reading the feed tells you that at least that many organizations saw it, and not which ones.

Who can use it. Enterprise only: the function calls the Enterprise plan gate and answers 403 with code: "plan_required" otherwise, and the dashboard tab shows an upgrade panel. contribute and subscribe additionally require the admin role on the organization; feed, match and status are open to any member.

Auth lane. Console-only, POST with a dashboard session. shield-threat-sharing is absent from the worker allowlist, so there is no public URL. The function resolves your organization from your own membership rows and ignores any tenant_id you put in the body; a user who belongs to several organizations cannot choose which one acts here.

Opt in first. contribute, feed and match require a subscription with sharing enabled, otherwise they answer 403 Enable threat sharing before contributing (contribute) or 403 Enable threat sharing to access the feed (feed and match). Turning sharing off stops every direction.

Five actions: contribute, feed, match, subscribe, status. Anything else answers 400 Unknown action, and a non-POST method answers 405 Method not allowed.

Actions and responses

contribute. Submit up to 100 indicators in one call. Admin only.

{
  "action": "contribute",
  "indicators": [
    { "indicator_hash": "<64 hex characters: the SHA-256 of +14155550123>", "indicator_type": "phone_hash", "severity": "high", "source_signal_type": "sim_swap" }
  ]
}

Each entry needs indicator_hash, the SHA-256 digest of the normalized indicator written as 64 hexadecimal characters, and indicator_type from the ten values below. Anything else in indicator_hash, a raw phone number, address or IP included, is refused with 400 before anything is read or stored. severity is optional and defaults to medium. source_signal_type is optional and must be a signal type such as sim_swap: lower-case letters, digits and underscores, starting with a letter, at most 64 characters.

The digest you send is an unkeyed SHA-256. For a phone number, anyone who obtains it can recover the number by hashing candidate numbers until one matches, so PasskeyBridge handles it as the phone number itself. What the function does with each indicator_hash, in this order:

  1. Checks that every entry in the request is valid. An invalid request is refused with 400 and nothing is read or stored.
  2. Folds the digest to lower case.
  3. Computes HMAC-SHA-256 with the platform's server-held key over the text pb-value-v1:threat-indicator:<indicator_type>:<digest> and writes the result as 64 lower-case hexadecimal characters. This is the keyed value. It runs straight after validation, before the subscription check, the match rate limit and any read of or write to the shared pool.
  4. Drops the indicators from the request. From this point the function holds only the keyed values: the database stores them, match compares them and the feed returns them.

The digest you sent is not written to the database, to a log, to the audit trail or to a rate-limit counter, and no response contains it, error responses included. If the platform cannot compute the keyed value, the call answers 500 and nothing is read or stored.

The server-held key is one key for the whole platform, so every organization's digest of the same normalized value produces the same keyed value, and reports from different organizations count toward one indicator without anyone agreeing a key. When the platform replaces the key, it keeps the previous key for an overlap period: a contribution of an indicator already stored under the previous key is added to that stored row, and match compares under both keys. A value seen for the first time starts a new indicator; each organization is counted once per indicator, and a repeat report from yours is skipped. The reply is {"contributed": 3, "skipped": 1, "failed": 0}, where failed counts indicators the database refused, and a threat_sharing.contribute audit entry records the totals.

indicator_typeDashboard label
ip_hashIP address
email_hashEmail
device_hashDevice
credential_hashCredential
phone_hashPhone
sim_hashSIM signal
agent_hashAgent ID
domain_hashOther
fingerprint_hashOther
session_hashOther

The last three are accepted by the API but have no label in the dashboard table, so they display as "Other".

feed. Returns indicators at or above your reporter threshold, newest activity first.

{ "action": "feed", "limit": 100, "severity": "critical", "indicator_type": "phone_hash" }

limit defaults to 50 and is capped at 200. Both filters are optional and validated against the same value lists. There is no age filter: the feed is the whole qualifying pool, ordered by last_seen_at.

{
  "indicators": [
    { "id": "<uuid>", "indicator_hash": "<keyed value>", "indicator_type": "phone_hash", "severity": "high", "contributor_count": 5, "first_seen_at": "...", "last_seen_at": "...", "metadata": {} }
  ],
  "own_contribution_ids": ["<uuid>"],
  "threshold": 3
}

indicator_hash in a feed row is the keyed value, which no organization can recompute from a value it holds. To ask whether a value you hold is in the pool, use match.

match. Checks your own digests against the pool. Any member.

{ "action": "match", "indicators": [ { "indicator_hash": "<64 hex characters: the SHA-256 of +14155550123>", "indicator_type": "phone_hash" } ] }

Up to 100 entries, each validated as a contribution is; only the digest and the type are read. The reply lists the entries that are in the pool at or above your reporter threshold, by their position in your request, with the row exactly as the feed shows it:

{
  "matches": [
    { "index": 0, "indicator_type": "phone_hash", "indicator_hash": "<keyed value>", "severity": "high", "contributor_count": 4, "first_seen_at": "...", "last_seen_at": "..." }
  ],
  "checked": 1,
  "threshold": 3
}

An indicator below the threshold is never reported, one your own organization contributed included. Each organization may make 30 match calls a minute; beyond that the function answers 429 with Retry-After. Each call writes a threat_sharing.match audit entry holding the number of entries checked and matched, never a digest.

subscribe. Admin only. {"action":"subscribe","sharing_enabled":true,"auto_contribute":false,"min_contributor_threshold":5} answers {"ok":true,"sharing_enabled":true} and writes a threat_sharing.subscribe or threat_sharing.unsubscribe audit entry. auto_contribute is stored, but no code reads it: nothing contributes on your behalf today, and every indicator in the pool got there through an explicit contribute call.

status. {"action":"status"} answers with your subscription, your own contribution count, the number of indicators currently above your threshold, and a severity breakdown of them.

StatusBodyCause
400{"error":"indicators array required"}empty or missing array
400{"error":"Max 100 indicators per request"}more than 100 entries
400{"error":"Each indicator_hash must be the SHA-256 hex digest of the normalized indicator (64 hexadecimal characters)"}a missing value, or anything but 64 hexadecimal characters, a raw identifier included
400{"error":"Invalid indicator_type at index 2. Allowed: ..."}type outside the ten; the message gives the entry's position, never the value you sent
400{"error":"Invalid severity at index 2. Allowed: critical, high, medium, low"}unknown severity; the message gives the entry's position, never the value you sent
400{"error":"source_signal_type must be a signal type such as sim_swap: ..."}free text, an address or a number in source_signal_type
401{"error":"Missing authorization"} or {"error":"Invalid auth token"}no session
403{"error":"Admin role required"}member without the admin role on contribute or subscribe
403{"error":"No tenant membership"}the caller belongs to no organization
429{"error":"At most 30 match requests a minute; try again shortly"}the organization's match limit
500{"error":"Indicators could not be keyed; nothing was recorded"}contribute: the platform could not key the digests, or could not read the pool during a key overlap; no contribution was written
500{"error":"Indicators could not be checked"}match: the digests could not be keyed or the pool could not be read

Reporter threshold

An indicator is withheld until enough separate organizations have reported it. The floor is 3 and it cannot be lowered: the feed and match both use the larger of your min_contributor_threshold and 3, so setting 1 or 2 has no effect while setting 5 makes your own reads stricter than the floor.

The reason for the floor is what a smaller number would leak. At 1, the feed would show that one organization saw a specific value. At 2, an organization that knows it contributed can deduce that exactly one other did. At 3, a reader learns only that at least three organizations saw it.

contributor_count on each feed row tells you how many organizations reported that indicator, and own_contribution_ids tells you which rows are yours, which is how the dashboard shows the "You" badge. Neither reveals who the others are; no identifier of a contributing organization is returned in any response.

Your own contributions are always visible to you in your contribution count, whether or not the indicator has reached the threshold.

Hashing and privacy

The platform keys every indicator, and refuses anything that is not a digest. The function accepts an indicator_hash only as a SHA-256 hex digest, so a raw phone number, email address or IP is refused before it can be stored. It then keys the digest with HMAC-SHA-256 under the platform's server-held key and stores only the keyed value; the database itself refuses any other shape. An unkeyed SHA-256 of a phone number, an email address or an IP address is enumerable, because those come from small spaces an attacker can hash exhaustively. The keyed value is not: without the key, which never leaves the platform's secret store, nothing can be precomputed. That is the same discipline PasskeyBridge applies to the identifiers it stores itself, described in Zero-PII compliance.

The digest you send travels to the function over TLS. The function keys it straight after validating the request and drops it; the steps are listed under contribute above. It is not stored, not logged, not returned in any response and not shown to other organizations. Handle it in your own systems as you handle the phone number it was computed from.

Practical rules for a contributor:

  • Normalize before you digest, so every organization produces the same digest for the same value. For a phone number that is E.164: a plus sign, the country code and the national number, digits only, with no spaces or punctuation (+14155550123). Take the SHA-256 of the UTF-8 bytes of that string and send it as hexadecimal. 14155550123 or +1 415 555 0123 produce different digests and do not match +14155550123. For an email use the trimmed lower-case address, for an IPv4 address the dotted decimal form and for IPv6 the RFC 5952 form. For the other types, agree the input form with the organizations you exchange with. Hex case does not matter; the function folds it.
  • Submit the digest of the value itself. A digest under a key of your own will not match any other organization's report of the same value.
  • source_signal_type is stored with your contribution and not shown to other organizations. It accepts a signal type token only.
  • A contribution cannot be withdrawn through the API: there is no delete action. When an organization is removed, its contributions are deleted with it, and each indicator's reporter count goes down by one.

What is shared with other organizations: the keyed value, its type, its severity, the number of reporting organizations, and the first and last time it was seen. What is not: the digest you sent, your organization's identity, your source_signal_type, your event rows, or any field of the signal the indicator came from.

Anyone with sharing on can learn whether a value they hold is in the pool at or above the threshold, through match. That is the purpose of the pool, and it discloses nothing the feed does not: a value below the threshold is never reported, and each call is rate limited and audited.

Threat sharing tab

Dashboard > Threat sharing, in the Security group, on Enterprise.

  • Sharing switch in the tab header turns the subscription on and off. Everything else stays empty until it is on.
  • Four tiles: Shared indicators (the pool above your threshold), Contributed (your own count), Critical (critical-severity indicators above the threshold), Minimum reporters (your threshold).
  • Sharing settings, a collapsed section with the Minimum reporters value. Automatic contribution is not offered: every indicator in the pool got there through an explicit contribute call.
  • Shared feed, a collapsed section with severity filter buttons (All, Critical, High, Medium, Low), a refresh control, and a table of Type, Indicator hash (the keyed value), Severity, Reporters, First seen, Last seen and Source. Rows you contributed carry a "You" badge; the rest show "Network". The table has no free-text search and no pagination beyond the 100-row fetch.

There is no contribute or match form in the dashboard. Submitting indicators and checking your own values mean calling contribute and match from your own code with a dashboard session. The tab is for reading the feed and managing the subscription.

Confirm it worked. After a contribute call, the Contributed tile increases by the number reported as contributed, and Dashboard > Audit log shows a threat_sharing.contribute entry with the contributed and skipped counts.

Related from the blog