Diagnostic Slack
installs keyed on team_id alone collide on Enterprise Grid
Two customers file the same ticket in the same week: messages from their Slack app are arriving in a channel that belongs to somebody else. Both are on the same Enterprise Grid organisation. Your installation store has one row per team_id, it has always had one row per team_id, and on a single-workspace customer that was correct. On Grid it is a cross-tenant data leak with a green dashboard.
Take every row in your installation store, call auth.test with that row's token, and compare what comes back — enterprise_id, team_id, is_enterprise_install — against the key you filed it under. A row that does not round-trip is a live risk, not a tidiness problem.
Three findings, in order of seriousness: two rows with the same team_id under different enterprise_id values, a row where is_enterprise_install is true but the key is a workspace id, and a row where enterprise_id was never persisted at all. The repair is to re-key on the triple (enterprise_id, team_id, is_enterprise_install) and to re-run auth.test per token to backfill.
The problem in plain words
An installation store written for a single workspace has an obvious key: the workspace. team_id is unique, it is in every event payload, and it is the value Slack hands you in the OAuth response. Nothing in the API pushes back, nothing in the SDK complains, and for every non-Grid customer the design is correct for as long as they stay non-Grid.
Enterprise Grid changes the identity without changing the shape of the payload. An org contains many workspaces. An app can be installed into one workspace, into several, or org-wide across all of them — and an org-wide installation's team_id may be null, because it is not scoped to a workspace at all. The install is identified by enterprise_id together with team_id and the is_enterprise_install flag; any one of the three on its own is ambiguous.
What makes this different from every other note in this section is the failure mode. Most Slack bugs end in an absence: a message that was never posted, a page that was never read. This one ends in a presence. A lookup keyed on team_id finds a row, that row holds a valid token, the call succeeds with ok: true, and one customer's data is written into another customer's workspace. There is no error to catch, and the tenant on the receiving end is the one who reports it.
Why it happens
The install identity is a triple, not a scalar. (enterprise_id, team_id, is_enterprise_install) is what Bolt's InstallationQuery passes to fetchInstallation, in both the JS and Python SDKs, and it is passed as three fields precisely because no subset of it is sufficient.
An org-wide install cannot be filed under a workspace. When is_enterprise_install is true the grant covers every workspace in the org, present and future. Storing it under one team_id means the other workspaces either find nothing or, worse, find a row written by a different install and use its token.
Lookup needs a fallback, not just a key. An event arriving from workspace T2 inside org E1 should prefer an exact (E1, T2) row and fall back to the org-wide (E1, null, true) row. A store that only does exact matching will silently drop events for workspaces that are covered by an org-wide grant.
A non-Grid customer can become a Grid customer overnight. Workspace-to-org migration is an admin action you never see. The API's warning shot is team_added_to_org on calls made mid-migration; your store finds out when the ids it has stop meaning what they meant.
Deleting is as dangerous as reading. A deleteInstallation implemented on team_id alone will, on an uninstall from one workspace, remove the row another tenant is using. The audit below is read-only for exactly this reason: the repair is a migration a human should run deliberately.
The fix, as a flow
The script compares each stored key against the identity its own token reports, then looks across rows for the collision itself. Both halves are needed: a row can look perfectly consistent on its own and still be the second of two rows fighting over one key.
How to fix it
Export the store as rows, keys included
The script takes a JSON array of the rows as you actually hold them: the key you filed each install under, whatever you persisted about it, and the environment variable holding that row's token. Do not normalise the export — the whole audit is a comparison between what you stored and what Slack says, so a helpfully cleaned-up export destroys the finding.
Ask each token who it is
auth.test is the only method that answers this. For a Grid install it returns enterprise_id and enterprise_name alongside team_id, plus is_enterprise_install. It needs no scopes, it is a GET, and it is the ground truth for the row that supplied the token.
Check that every row round-trips
A row round-trips when the key you would compute from the live auth.test answer is the key the row is filed under. Anything else — a dropped enterprise_id, an org-wide install under a workspace key, a key that names a different team than the token does — means a lookup can return the wrong token.
Look across rows for the collision itself
Per-row checks miss the finding that matters most: two rows carrying the same team_id under different enterprise_id values, or one key that two distinct identities both map to. That is not a risk of leakage, it is leakage already happening on whichever row was written second.
Size the org before you estimate the blast radius
admin.teams.list reports how many workspaces the org contains, which converts "one collision" into "one collision across forty workspaces". It needs admin.teams:read on a user token from an org admin, so treat it as optional context rather than part of the detection.
Re-key, then backfill by re-running auth.test
The migration is mechanical: widen the key to the triple with enterprise_id nullable, implement lookup as exact-match-then-org-wide-fallback, and populate the new column by calling auth.test once per stored token. The script prints this and performs none of it.
How to check it worked
After the migration, re-run over the same export. Every row should round-trip and no team_id should appear under two organisations.
python3 slack_install_key_audit.py --store installs.json
# 12 install(s) checked, 0 keyed in a way that can collide
The full code
One GET per stored token, and no writes anywhere — this script reads credentials belonging to several tenants at once, which is precisely why it must never be able to change one. Both classifiers are pure: verdict compares a single row against its live identity, and collisions looks across rows for the two shapes that a per-row check cannot see.
"""Audit a Slack installation store for keys that collide on Enterprise Grid.
Read only. GET requests and nothing else, because this script is handed one
token per tenant and a mistake here is a cross-tenant one. The repair is a store
migration; it is printed for a human to run.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_install_key_audit")
API = "https://slack.com/api/"
def verdict(stored, identity):
"""Compare one stored installation row against what its token says it is.
`stored` is the row as your store holds it: a `key`, and whatever else was
persisted (`enterprise_id`, `is_enterprise_install`). `identity` is the
parsed auth.test body for that row's token. Pure, so the whole truth table
runs offline.
"""
if identity.get("ok") is not True:
return ("unusable",
"auth.test answered ok: false, error=%s. The row cannot be "
"checked, and a token that no longer authenticates is its own "
"finding." % (identity.get("error") or "<no error field>"))
live_team = identity.get("team_id")
live_ent = identity.get("enterprise_id")
org_install = identity.get("is_enterprise_install") is True
key = str(stored.get("key", ""))
stored_ent = stored.get("enterprise_id")
stored_org = stored.get("is_enterprise_install") is True
if live_ent and not stored_ent:
return ("enterprise-id-dropped",
"live install is in org %s and the row kept no enterprise_id. "
"Two workspaces in different orgs can now be filed under one "
"key, and the second write wins." % live_ent)
if live_ent and stored_ent != live_ent:
return ("enterprise-id-wrong",
"row says org %s, the token says %s. A lookup on this row hands "
"out a credential belonging to another organisation."
% (stored_ent, live_ent))
if org_install and not stored_org:
return ("org-install-under-team-key",
"is_enterprise_install is true but the row is filed as a "
"workspace install under %r. The grant covers every workspace "
"in the org, including ones with no row at all." % key)
if stored_org and not org_install:
return ("workspace-install-flagged-org",
"the row claims an org-wide install and the token is scoped to "
"workspace %s. Lookups for sibling workspaces will match this "
"row and use a token that cannot serve them." % live_team)
if live_team and key not in (live_team, "%s.%s" % (live_ent, live_team)):
return ("key-drift",
"row is filed under %r and the token reports team %s. The key "
"does not round-trip, so whatever wrote it is not what reads it."
% (key, live_team))
if live_ent:
return ("grid-keyed",
"org %s, team %s, org-wide=%s, all three persisted"
% (live_ent, live_team, org_install))
return ("single-workspace",
"team %s, not on Grid. team_id alone is adequate today and stops "
"being adequate the day this customer migrates to an org."
% live_team)
def collisions(seen):
"""Find cross-row collisions. Pure.
`seen` is a list of dicts with `key`, `team_id` and `enterprise_id`. Returns
(team_collisions, key_collisions): team ids that appear under more than one
organisation, and store keys that resolve to more than one live identity.
Neither is visible from a single row, and both are leakage in progress.
"""
by_team = {}
by_key = {}
for row in seen:
team = row.get("team_id")
if team:
by_team.setdefault(team, set()).add(row.get("enterprise_id") or "")
by_key.setdefault(str(row.get("key", "")), set()).add(
(row.get("enterprise_id") or "", team or ""))
team_collisions = sorted(t for t, orgs in by_team.items() if len(orgs) > 1)
key_collisions = sorted(k for k, ids in by_key.items() if len(ids) > 1)
return team_collisions, key_collisions
def auth_test(session, token):
r = session.get(API + "auth.test", headers={"Authorization": "Bearer " + token},
timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def load_rows(path):
"""Rows as the store holds them, not as it wishes it held them."""
if path:
return json.loads(open(path, encoding="utf-8").read())
return [{"key": os.environ.get("SLACK_TEAM_ID", "<the only row>"),
"token_env": "SLACK_BOT_TOKEN"}]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--store", help="JSON array of installation rows; each row needs "
"key and token_env, plus whatever else you persist")
args = ap.parse_args()
if not args.store and not os.environ.get("SLACK_BOT_TOKEN"):
log.error("set SLACK_BOT_TOKEN, or pass --store with one token_env per row")
return 2
rows = load_rows(args.store)
s = requests.Session()
seen = []
bad = 0
for row in rows:
token = os.environ.get(row.get("token_env") or "SLACK_BOT_TOKEN")
if not token:
log.warning("%-28s %s", "no-token", "row %r names %s and it is unset"
% (row.get("key"), row.get("token_env")))
bad += 1
continue
identity = auth_test(s, token)
state, detail = verdict(row, identity)
line = "%-28s %-18s %s" % (state, row.get("key"), detail)
if state in ("grid-keyed", "single-workspace"):
log.info(line)
else:
bad += 1
log.warning(line)
log.warning(" repair: key this store on (enterprise_id, team_id, "
"is_enterprise_install), enterprise_id nullable")
if identity.get("ok") is True:
seen.append({"key": row.get("key"),
"team_id": identity.get("team_id"),
"enterprise_id": identity.get("enterprise_id")})
team_collisions, key_collisions = collisions(seen)
for team in team_collisions:
bad += 1
log.warning("%-28s %s", "team-id-in-two-orgs",
"team %s is filed under more than one enterprise_id" % team)
for key in key_collisions:
bad += 1
log.warning("%-28s %s", "key-serves-two-installs",
"store key %r resolves to more than one live identity" % key)
if team_collisions or key_collisions:
log.warning(" repair: migrate before the next uninstall. A delete keyed on "
"team_id alone removes another tenant's row")
log.info("%d install(s) checked, %d keyed in a way that can collide",
len(rows), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Audit a Slack installation store for keys that collide on Enterprise Grid.
*
* Read only. GET requests and nothing else, because this script is handed one
* token per tenant and a mistake here is a cross-tenant one. The repair is a
* store migration; it is printed for a human to run.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
/**
* Compare one stored installation row against what its token says it is.
* Pure, so the whole truth table runs offline.
*/
export function verdict(stored, identity) {
if (identity?.ok !== true) {
return ['unusable',
`auth.test answered ok: false, error=${identity?.error ?? '<no error field>'}. ` +
'The row cannot be checked, and a token that no longer authenticates is its ' +
'own finding.'];
}
const liveTeam = identity.team_id;
const liveEnt = identity.enterprise_id;
const orgInstall = identity.is_enterprise_install === true;
const key = String(stored.key ?? '');
const storedEnt = stored.enterprise_id;
const storedOrg = stored.is_enterprise_install === true;
if (liveEnt && !storedEnt) {
return ['enterprise-id-dropped',
`live install is in org ${liveEnt} and the row kept no enterprise_id. Two ` +
'workspaces in different orgs can now be filed under one key, and the ' +
'second write wins.'];
}
if (liveEnt && storedEnt !== liveEnt) {
return ['enterprise-id-wrong',
`row says org ${storedEnt}, the token says ${liveEnt}. A lookup on this row ` +
'hands out a credential belonging to another organisation.'];
}
if (orgInstall && !storedOrg) {
return ['org-install-under-team-key',
`is_enterprise_install is true but the row is filed as a workspace install ` +
`under ${JSON.stringify(key)}. The grant covers every workspace in the org, ` +
'including ones with no row at all.'];
}
if (storedOrg && !orgInstall) {
return ['workspace-install-flagged-org',
`the row claims an org-wide install and the token is scoped to workspace ` +
`${liveTeam}. Lookups for sibling workspaces will match this row and use a ` +
'token that cannot serve them.'];
}
if (liveTeam && key !== liveTeam && key !== `${liveEnt}.${liveTeam}`) {
return ['key-drift',
`row is filed under ${JSON.stringify(key)} and the token reports team ` +
`${liveTeam}. The key does not round-trip, so whatever wrote it is not what ` +
'reads it.'];
}
if (liveEnt) {
return ['grid-keyed',
`org ${liveEnt}, team ${liveTeam}, org-wide=${orgInstall}, all three persisted`];
}
return ['single-workspace',
`team ${liveTeam}, not on Grid. team_id alone is adequate today and stops being ` +
'adequate the day this customer migrates to an org.'];
}
/**
* Find cross-row collisions. Pure. Returns [teamCollisions, keyCollisions]:
* team ids filed under more than one organisation, and store keys that resolve
* to more than one live identity.
*/
export function collisions(seen) {
const byTeam = new Map();
const byKey = new Map();
for (const row of seen) {
const team = row.team_id;
if (team) {
if (!byTeam.has(team)) byTeam.set(team, new Set());
byTeam.get(team).add(row.enterprise_id ?? '');
}
const key = String(row.key ?? '');
if (!byKey.has(key)) byKey.set(key, new Set());
byKey.get(key).add(`${row.enterprise_id ?? ''}|${team ?? ''}`);
}
const teamCollisions = [...byTeam.entries()]
.filter(([, orgs]) => orgs.size > 1).map(([t]) => t).sort();
const keyCollisions = [...byKey.entries()]
.filter(([, ids]) => ids.size > 1).map(([k]) => k).sort();
return [teamCollisions, keyCollisions];
}
async function authTest(token) {
const res = await fetch(API + 'auth.test', {
headers: { Authorization: `Bearer ${token}` },
});
try {
return await res.json();
} catch {
return { ok: false, error: 'unparseable_body' };
}
}
async function loadRows(path) {
if (path) return JSON.parse(await readFile(path, 'utf8'));
return [{ key: process.env.SLACK_TEAM_ID ?? '<the only row>', token_env: 'SLACK_BOT_TOKEN' }];
}
async function main() {
const args = process.argv.slice(2);
const i = args.indexOf('--store');
const store = i === -1 ? null : args[i + 1];
if (!store && !process.env.SLACK_BOT_TOKEN) {
console.error('set SLACK_BOT_TOKEN, or pass --store with one token_env per row');
process.exitCode = 2;
return;
}
const rows = await loadRows(store);
const seen = [];
let bad = 0;
for (const row of rows) {
const token = process.env[row.token_env ?? 'SLACK_BOT_TOKEN'];
if (!token) {
console.warn(`${'no-token'.padEnd(28)} row ${JSON.stringify(row.key)} names ` +
`${row.token_env} and it is unset`);
bad += 1;
continue;
}
const identity = await authTest(token);
const [state, detail] = verdict(row, identity);
const line = `${state.padEnd(28)} ${String(row.key).padEnd(18)} ${detail}`;
if (state === 'grid-keyed' || state === 'single-workspace') {
console.log(line);
} else {
bad += 1;
console.warn(line);
console.warn(' repair: key this store on (enterprise_id, team_id, ' +
'is_enterprise_install), enterprise_id nullable');
}
if (identity?.ok === true) {
seen.push({ key: row.key, team_id: identity.team_id, enterprise_id: identity.enterprise_id });
}
}
const [teamCollisions, keyCollisions] = collisions(seen);
for (const team of teamCollisions) {
bad += 1;
console.warn(`${'team-id-in-two-orgs'.padEnd(28)} team ${team} is filed under ` +
'more than one enterprise_id');
}
for (const key of keyCollisions) {
bad += 1;
console.warn(`${'key-serves-two-installs'.padEnd(28)} store key ` +
`${JSON.stringify(key)} resolves to more than one live identity`);
}
if (teamCollisions.length || keyCollisions.length) {
console.warn(' repair: migrate before the next uninstall. A delete keyed on ' +
'team_id alone removes another tenant\'s row');
}
console.log(`${rows.length} install(s) checked, ${bad} keyed in a way that can collide`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The row worth pinning is the one that looks healthiest: a workspace install on a non-Grid customer, filed under team_id, with no enterprise_id to store. It must not be reported as a finding, or the audit cries wolf on every ordinary customer — and it must not be reported as safe forever either, because the day that workspace joins an org the same row becomes the leak.
from slack_install_key_audit import collisions, verdict
def test_grid_install_with_no_stored_enterprise_id_is_the_finding():
stored = {"key": "T111"}
live = {"ok": True, "team_id": "T111", "enterprise_id": "E999",
"is_enterprise_install": False}
state, detail = verdict(stored, live)
assert state == "enterprise-id-dropped"
assert "E999" in detail
def test_org_wide_install_filed_under_a_workspace_key():
stored = {"key": "T111", "enterprise_id": "E999"}
live = {"ok": True, "team_id": None, "enterprise_id": "E999",
"is_enterprise_install": True}
assert verdict(stored, live)[0] == "org-install-under-team-key"
def test_row_pointing_at_another_org_is_a_credential_handout():
stored = {"key": "E1.T111", "enterprise_id": "E1"}
live = {"ok": True, "team_id": "T111", "enterprise_id": "E2",
"is_enterprise_install": False}
state, detail = verdict(stored, live)
assert state == "enterprise-id-wrong"
assert "another organisation" in detail
def test_plain_workspace_install_is_not_reported():
stored = {"key": "T111"}
live = {"ok": True, "team_id": "T111", "enterprise_id": None,
"is_enterprise_install": False}
state, detail = verdict(stored, live)
assert state == "single-workspace"
assert "migrates to an org" in detail
def test_key_that_does_not_round_trip():
stored = {"key": "T222"}
live = {"ok": True, "team_id": "T111", "enterprise_id": None,
"is_enterprise_install": False}
assert verdict(stored, live)[0] == "key-drift"
def test_dead_token_is_reported_rather_than_guessed_at():
assert verdict({"key": "T111"}, {"ok": False, "error": "token_revoked"})[0] == "unusable"
def test_same_team_under_two_orgs_is_a_cross_row_finding():
seen = [{"key": "T111", "team_id": "T111", "enterprise_id": "E1"},
{"key": "T111", "team_id": "T111", "enterprise_id": "E2"}]
teams, keys = collisions(seen)
assert teams == ["T111"]
assert keys == ["T111"]
def test_distinct_installs_do_not_collide():
seen = [{"key": "E1.T111", "team_id": "T111", "enterprise_id": "E1"},
{"key": "E2.T222", "team_id": "T222", "enterprise_id": "E2"}]
assert collisions(seen) == ([], [])
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { collisions, verdict } from './slack-install-key-audit.mjs';
test('grid install with no stored enterprise_id is the finding', () => {
const [state, detail] = verdict(
{ key: 'T111' },
{ ok: true, team_id: 'T111', enterprise_id: 'E999', is_enterprise_install: false },
);
assert.equal(state, 'enterprise-id-dropped');
assert.match(detail, /E999/);
});
test('org wide install filed under a workspace key', () => {
const [state] = verdict(
{ key: 'T111', enterprise_id: 'E999' },
{ ok: true, team_id: null, enterprise_id: 'E999', is_enterprise_install: true },
);
assert.equal(state, 'org-install-under-team-key');
});
test('row pointing at another org is a credential handout', () => {
const [state, detail] = verdict(
{ key: 'E1.T111', enterprise_id: 'E1' },
{ ok: true, team_id: 'T111', enterprise_id: 'E2', is_enterprise_install: false },
);
assert.equal(state, 'enterprise-id-wrong');
assert.match(detail, /another organisation/);
});
test('plain workspace install is not reported', () => {
const [state, detail] = verdict(
{ key: 'T111' },
{ ok: true, team_id: 'T111', enterprise_id: null, is_enterprise_install: false },
);
assert.equal(state, 'single-workspace');
assert.match(detail, /migrates to an org/);
});
test('key that does not round trip', () => {
const [state] = verdict(
{ key: 'T222' },
{ ok: true, team_id: 'T111', enterprise_id: null, is_enterprise_install: false },
);
assert.equal(state, 'key-drift');
});
test('dead token is reported rather than guessed at', () => {
assert.equal(verdict({ key: 'T111' }, { ok: false, error: 'token_revoked' })[0], 'unusable');
});
test('same team under two orgs is a cross row finding', () => {
const [teams, keys] = collisions([
{ key: 'T111', team_id: 'T111', enterprise_id: 'E1' },
{ key: 'T111', team_id: 'T111', enterprise_id: 'E2' },
]);
assert.deepEqual(teams, ['T111']);
assert.deepEqual(keys, ['T111']);
});
test('distinct installs do not collide', () => {
const [teams, keys] = collisions([
{ key: 'E1.T111', team_id: 'T111', enterprise_id: 'E1' },
{ key: 'E2.T222', team_id: 'T222', enterprise_id: 'E2' },
]);
assert.deepEqual(teams, []);
assert.deepEqual(keys, []);
});
FAQ
Is this really a security issue rather than a bug?
Yes. The outcome is one tenant's token being used to act in another tenant's workspace, inside the same Enterprise Grid organisation. Treat it the way you would treat any cross-tenant key collision: the audit is read-only, the migration is deliberate, and the customers whose rows collided are the ones who can tell you what was written where.
Why can an org-wide install have a null team_id?
Because it is not scoped to a workspace. When is_enterprise_install is true the grant applies across the organisation, so there is no single team to name. Any store whose primary key is team_id has nowhere to put that row, which is why it ends up filed under whichever workspace happened to be in the payload.
Do I need admin scopes to run the audit?
No. The detection is auth.test per stored token, which needs no scopes at all. admin.teams.list is optional and only sizes the org, and it needs admin.teams:read on a user token belonging to an org admin, which is a different credential class from the bot token the audit otherwise uses.
What should lookup do once the key is a triple?
Prefer the exact workspace row for (enterprise_id, team_id), and fall back to the org-wide row for (enterprise_id, null, true) when there is no exact match. Without that fallback, an app installed org-wide stops serving every workspace that never had its own row written.
Can I detect this without an export of my store?
Only partially. With a single token the script can tell you whether that install is on Grid and whether you kept its enterprise_id, which is the per-row half of the finding. The collision half is a comparison between rows, so it needs the rows.
Related field notes
- every failure arrives as HTTP 200
- missing_scope names the scope you need
- Slack turned event delivery off
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- auth.test method reference — Slack Docs
- Installing with OAuth — Slack Docs
- Enterprise Grid and org-wide apps — Slack Docs
- admin.teams.list method reference — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.