Diagnostic Slack
account_inactive: the installer left and took the token
The nightly export ran for two years and stopped on a Tuesday. Nothing was deployed, no scope changed, the app is still listed in the workspace. The error is {"ok": false, "error": "account_inactive"}, and the explanation is in the HR system rather than yours: the engineer who installed the app in 2024 left the company on Monday, and SSO deprovisioning deactivated her account overnight.
Two questions, and the second is the one worth running. First, auth.test per stored token: account_inactive means the human behind a user token was deactivated and that automation is already down. Second, and before anything breaks: take the authed_user.id you persisted at install, look each one up in the member directory, and find every automation that is currently standing on one employee.
The repair is a token class, not a token. Move the work to a bot token, which survives the installer's departure entirely. Where a user token is genuinely required — message search, acting as a person — install from a service account that offboarding does not touch, and watch its deleted flag.
The problem in plain words
A user token is a credential issued to a person. It carries their identity, sees what they can see, and is cancelled when their account is. That is correct behaviour and it is precisely what makes it the wrong credential for a job that has to run every night for three years. The failure is not gradual: the account is deactivated at 02:00 and the integration is dead at 02:01, with no warning and no window in which anything could have been renewed.
Almost nobody chooses this on purpose. A user token gets adopted because one call needed a user scope, or because the OAuth response's first token was the one that got stored, or because the app was set up during a hackathon by whoever happened to be at the keyboard. From that moment the integration has an undocumented dependency on one employee remaining employed, recorded nowhere except in an authed_user.id field that most stores do not even keep.
And the app looks installed the whole time, because it is. Nobody removed it. The workspace admin's Manage apps page shows it present and healthy, which is why the first hour of the investigation is usually spent looking at the app configuration — the one place where nothing is wrong.
Why it happens
Bot tokens are immune and that is the point of them. A xoxb- token belongs to the app, not to a person. It survives the installer leaving, changing teams, or losing their laptop. Slack's own guidance is to use it for anything that must outlive an individual, and this error is the sharpest illustration of why.
The identity you need was in the install response and probably discarded. authed_user.id is the id of the human whose token you are holding. Without it you cannot ask whether they are still here; you find out when the automation stops. Persisting it costs one column and converts this failure into something a scheduled read can see coming.
A deactivated member is visible before the token fails. users.list and users.info report deleted: true for deactivated accounts, so a join between your install rows and the member directory produces a risk register: which automations depend on which humans, and which of those humans have already gone.
Guests go first. is_restricted and is_ultra_restricted mark multi-channel and single-channel guests, and guest accounts are typically deprovisioned soonest and most abruptly. An install standing on a guest account is the highest-risk row in the register.
The id in your row and the id on the token can disagree. If a row records one installer and auth.test reports a different user_id, you are monitoring the wrong person: the token was replaced at some point and the row was not. That row is unmonitored even though it looks monitored, which is worse than not having the column.
The fix, as a flow
The live half of this audit finds what already broke. The half worth running joins the installer ids against the member directory, which turns an outage into a register of which automations are standing on which people.
How to fix it
Persist the installer id, if you have not already
The audit needs authed_user.id per row. If your store never kept it, the script recovers it from auth.test and tells you to write it down — recovery works only while the token still authenticates, which is exactly the window this note is about.
Ask each token whether it still works, and what it is
auth.test answers both at once. account_inactive means already broken. A successful response carrying bot_id means a bot token, which has no exposure here at all and should be reported as such rather than filling the output with rows that cannot fail this way.
Read the member directory once
One paginated pass over users.list?limit=200 with a token holding users:read, following response_metadata.next_cursor to the end. One sweep is cheaper and kinder to the rate limiter than a users.info per install, and it also lets you count how much of the workspace is deactivated.
Join the installers against the directory
For each user token, look up its installer. Deleted means the automation is down or about to be. A guest account means it is fragile. An id that is not in the directory at all usually means the person has been removed entirely, or that the row belongs to a different workspace on the same Grid org.
Check that the row names the human the token actually belongs to
Compare the recorded installer against the user_id in the auth.test response. A mismatch means the token was swapped without updating the row, so your monitoring has been watching somebody who has nothing to do with this credential.
Move the work to a bot token, or to a service account
The printed repair is the same one Slack gives: reinstall with the equivalent bot scopes and use xoxb-. Where a user token is unavoidable, install from a documented service account that offboarding does not touch, and keep watching its deleted flag — a service account can be deactivated too, usually during a licence audit.
How to check it worked
After migrating, re-run. Every row should report a bot token, or a user token whose installer is a live, non-guest service account.
python3 slack_installer_account_watch.py --store installs.json
# bot-token exports bot token: no dependency on any human account
# 6 row(s) checked, 0 already broken, 0 standing on a live human
The full code
Two kinds of GET: one auth.test per stored token, and one paginated users.list sweep to build the directory. Nothing is written, and nothing about a person is changed — the script only reads whether an account is still active. Two pure functions do the work: directory turns a users.list page set into a lookup, and exposure decides what one install row is standing on.
"""Find Slack installs whose token depends on one human account still existing.
Read only. GET requests and nothing else: this script reads whether people are
still active, which is about as sensitive as workspace data gets, so it reports
and prints the repair rather than performing anything.
"""
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_installer_account_watch")
API = "https://slack.com/api/"
def directory(members):
"""Turn users.list members into an id -> record lookup. Pure.
Keeps only the four fields the audit reasons about, so the register that
comes out of this script is not a copy of the member list.
"""
out = {}
for m in members or []:
uid = m.get("id")
if not uid:
continue
out[uid] = {
"name": m.get("name") or m.get("real_name") or uid,
"deleted": m.get("deleted") is True,
"is_bot": m.get("is_bot") is True,
"guest": bool(m.get("is_restricted") or m.get("is_ultra_restricted")),
}
return out
def exposure(row, identity, people):
"""What is this installation standing on. Pure.
`row` is the stored installation record, `identity` the parsed auth.test
body for its token, `people` the directory lookup. The states are ordered so
that "already broken" is reported before "will break", and a bot token is
dismissed before any of the human reasoning runs.
"""
if identity.get("ok") is not True:
error = identity.get("error")
if error == "account_inactive":
return ("already-broken",
"account_inactive: this is a user token and its human was "
"deactivated. The app is still installed; the person is not.")
return ("other-failure",
"error=%s, which is not this failure. A revoked token or an "
"expired rotated one looks similar in a log line and wants a "
"different repair." % (error or "<no error field>"))
if identity.get("bot_id"):
return ("bot-token",
"bot token: no dependency on any human account. This row cannot "
"fail the way the others can.")
live_id = identity.get("user_id")
stored_id = row.get("installer")
if not stored_id:
return ("installer-not-recorded",
"a working user token and no authed_user id in the row. It "
"belongs to %s, recoverable only while the token still works: "
"persist it now." % live_id)
if stored_id != live_id:
return ("installer-id-drift",
"the row names %s and the token belongs to %s. Whatever you are "
"monitoring, it is not this credential." % (stored_id, live_id))
person = people.get(live_id)
if person is None:
return ("installer-not-in-directory",
"%s holds a working token and is not in this workspace's member "
"list. Usually a removed account, or a row belonging to a "
"different workspace in the same org." % live_id)
if person["deleted"]:
return ("directory-disagrees",
"%s is marked deleted and the token still authenticates. Read "
"this by hand before acting on it." % person["name"])
if person["guest"]:
return ("guest-installer",
"%s is a guest account. Guests are deprovisioned soonest and "
"most abruptly, which makes this the most fragile row here."
% person["name"])
return ("standing-on-a-human",
"user token belonging to %s, who is active today. This automation "
"stops on their last day." % person["name"])
def get(session, token, method, params=None):
r = session.get(API + method, params=params or {},
headers={"Authorization": "Bearer " + token}, timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def read_directory(session, token):
"""One paginated users.list sweep. Cheaper than users.info per install."""
members, cursor = [], ""
while True:
body = get(session, token, "users.list",
{"limit": "200", "cursor": cursor} if cursor else {"limit": "200"})
if body.get("ok") is not True:
return None, body.get("error")
members.extend(body.get("members") or [])
cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
if not cursor:
return members, None
def load_rows(path):
if path:
return json.loads(open(path, encoding="utf-8").read())
return [{"key": "<the only row>", "token_env": "SLACK_BOT_TOKEN",
"installer": os.environ.get("SLACK_INSTALLER_ID")}]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--store", help="JSON array of installation rows; each row needs "
"key and token_env, and installer if you kept it")
ap.add_argument("--directory-token-env", default="SLACK_BOT_TOKEN",
help="env var holding a users:read token for the member sweep")
args = ap.parse_args()
rows = load_rows(args.store)
s = requests.Session()
people = {}
dir_token = os.environ.get(args.directory_token_env)
if dir_token:
members, err = read_directory(s, dir_token)
if members is None:
log.warning("%-26s users.list refused: %s. The directory half of this "
"audit is unavailable and rows will be reported on liveness "
"alone.", "directory-unavailable", err)
else:
people = directory(members)
gone = sum(1 for p in people.values() if p["deleted"])
log.info("%-26s %d member(s), %d deactivated", "directory", len(people), gone)
else:
log.warning("%-26s %s is unset, so installers cannot be looked up",
"directory-unavailable", args.directory_token_env)
broken = 0
at_risk = 0
for row in rows:
token = os.environ.get(row.get("token_env") or "SLACK_BOT_TOKEN")
if not token:
log.warning("%-26s %-12s row names %s and it is unset", "no-token",
row.get("key"), row.get("token_env"))
continue
identity = get(s, token, "auth.test")
state, detail = exposure(row, identity, people)
line = "%-26s %-12s %s" % (state, row.get("key"), detail)
if state == "bot-token":
log.info(line)
continue
log.warning(line)
if state == "already-broken":
broken += 1
elif state in ("standing-on-a-human", "guest-installer",
"installer-not-in-directory"):
at_risk += 1
if state in ("already-broken", "standing-on-a-human", "guest-installer"):
log.warning(" repair: reinstall with the equivalent bot scopes and use "
"the xoxb- token, or install from a documented service "
"account that offboarding does not touch")
log.info("%d row(s) checked, %d already broken, %d standing on a live human",
len(rows), broken, at_risk)
return 1 if (broken or at_risk) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find Slack installs whose token depends on one human account still existing.
*
* Read only. GET requests and nothing else: this script reads whether people
* are still active, which is about as sensitive as workspace data gets, so it
* reports and prints the repair rather than performing anything.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
/**
* Turn users.list members into an id -> record lookup. Pure. Keeps only the
* four fields the audit reasons about, so the register that comes out of this
* script is not a copy of the member list.
*/
export function directory(members) {
const out = new Map();
for (const m of members ?? []) {
if (!m?.id) continue;
out.set(m.id, {
name: m.name || m.real_name || m.id,
deleted: m.deleted === true,
is_bot: m.is_bot === true,
guest: Boolean(m.is_restricted || m.is_ultra_restricted),
});
}
return out;
}
/**
* What is this installation standing on. Pure. The states are ordered so that
* "already broken" is reported before "will break", and a bot token is
* dismissed before any of the human reasoning runs.
*/
export function exposure(row, identity, people) {
if (identity?.ok !== true) {
const error = identity?.error;
if (error === 'account_inactive') {
return ['already-broken',
'account_inactive: this is a user token and its human was deactivated. ' +
'The app is still installed; the person is not.'];
}
return ['other-failure',
`error=${error ?? '<no error field>'}, which is not this failure. A revoked ` +
'token or an expired rotated one looks similar in a log line and wants a ' +
'different repair.'];
}
if (identity.bot_id) {
return ['bot-token',
'bot token: no dependency on any human account. This row cannot fail the ' +
'way the others can.'];
}
const liveId = identity.user_id;
const storedId = row.installer;
if (!storedId) {
return ['installer-not-recorded',
'a working user token and no authed_user id in the row. It belongs to ' +
`${liveId}, recoverable only while the token still works: persist it now.`];
}
if (storedId !== liveId) {
return ['installer-id-drift',
`the row names ${storedId} and the token belongs to ${liveId}. Whatever you ` +
'are monitoring, it is not this credential.'];
}
const person = people.get(liveId);
if (person === undefined) {
return ['installer-not-in-directory',
`${liveId} holds a working token and is not in this workspace's member list. ` +
'Usually a removed account, or a row belonging to a different workspace in ' +
'the same org.'];
}
if (person.deleted) {
return ['directory-disagrees',
`${person.name} is marked deleted and the token still authenticates. Read ` +
'this by hand before acting on it.'];
}
if (person.guest) {
return ['guest-installer',
`${person.name} is a guest account. Guests are deprovisioned soonest and most ` +
'abruptly, which makes this the most fragile row here.'];
}
return ['standing-on-a-human',
`user token belonging to ${person.name}, who is active today. This automation ` +
'stops on their last day.'];
}
async function get(token, method, params = {}) {
const url = new URL(API + method);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
try {
return await res.json();
} catch {
return { ok: false, error: 'unparseable_body' };
}
}
/** One paginated users.list sweep. Cheaper than users.info per install. */
async function readDirectory(token) {
const members = [];
let cursor = '';
for (;;) {
const params = cursor ? { limit: '200', cursor } : { limit: '200' };
const body = await get(token, 'users.list', params);
if (body?.ok !== true) return [null, body?.error];
members.push(...(body.members ?? []));
cursor = body.response_metadata?.next_cursor ?? '';
if (!cursor) return [members, null];
}
}
async function loadRows(path) {
if (path) return JSON.parse(await readFile(path, 'utf8'));
return [{ key: '<the only row>', token_env: 'SLACK_BOT_TOKEN',
installer: process.env.SLACK_INSTALLER_ID }];
}
async function main() {
const args = process.argv.slice(2);
const si = args.indexOf('--store');
const store = si === -1 ? null : args[si + 1];
const di = args.indexOf('--directory-token-env');
const dirEnv = di === -1 ? 'SLACK_BOT_TOKEN' : args[di + 1];
const rows = await loadRows(store);
let people = new Map();
const dirToken = process.env[dirEnv];
if (dirToken) {
const [members, err] = await readDirectory(dirToken);
if (members === null) {
console.warn(`${'directory-unavailable'.padEnd(26)} users.list refused: ${err}. ` +
'The directory half of this audit is unavailable and rows will be reported ' +
'on liveness alone.');
} else {
people = directory(members);
const gone = [...people.values()].filter((p) => p.deleted).length;
console.log(`${'directory'.padEnd(26)} ${people.size} member(s), ${gone} deactivated`);
}
} else {
console.warn(`${'directory-unavailable'.padEnd(26)} ${dirEnv} is unset, so ` +
'installers cannot be looked up');
}
let broken = 0;
let atRisk = 0;
for (const row of rows) {
const token = process.env[row.token_env ?? 'SLACK_BOT_TOKEN'];
if (!token) {
console.warn(`${'no-token'.padEnd(26)} ${String(row.key).padEnd(12)} row names ` +
`${row.token_env} and it is unset`);
continue;
}
const identity = await get(token, 'auth.test');
const [state, detail] = exposure(row, identity, people);
const line = `${state.padEnd(26)} ${String(row.key).padEnd(12)} ${detail}`;
if (state === 'bot-token') {
console.log(line);
continue;
}
console.warn(line);
if (state === 'already-broken') broken += 1;
else if (['standing-on-a-human', 'guest-installer',
'installer-not-in-directory'].includes(state)) atRisk += 1;
if (['already-broken', 'standing-on-a-human', 'guest-installer'].includes(state)) {
console.warn(' repair: reinstall with the equivalent bot scopes and use the ' +
'xoxb- token, or install from a documented service account that offboarding ' +
'does not touch');
}
}
console.log(`${rows.length} row(s) checked, ${broken} already broken, ` +
`${atRisk} standing on a live human`);
process.exitCode = broken || atRisk ? 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 the tests exist for is the one that is working perfectly: a user token whose installer is present, active and employed. It must be reported, because it is the whole point of running the audit before the offboarding rather than after it — and the bot token beside it must not be, or the register fills up with rows that cannot fail this way and nobody reads it twice.
from slack_installer_account_watch import directory, exposure
PEOPLE = directory([
{"id": "U_LIVE", "name": "dana", "deleted": False},
{"id": "U_GONE", "name": "sam", "deleted": True},
{"id": "U_GUEST", "name": "vendor", "deleted": False, "is_restricted": True},
])
def test_directory_keeps_only_what_the_audit_reasons_about():
assert PEOPLE["U_GUEST"] == {"name": "vendor", "deleted": False,
"is_bot": False, "guest": True}
assert "U_MISSING" not in PEOPLE
def test_account_inactive_is_the_already_broken_state():
state, detail = exposure({"key": "T1", "installer": "U_GONE"},
{"ok": False, "error": "account_inactive"}, PEOPLE)
assert state == "already-broken"
assert "still installed" in detail
def test_a_revoked_token_is_not_this_failure():
state, detail = exposure({"key": "T1"}, {"ok": False, "error": "token_revoked"}, PEOPLE)
assert state == "other-failure"
assert "token_revoked" in detail
def test_bot_token_has_no_exposure_at_all():
state, detail = exposure({"key": "T1"},
{"ok": True, "user_id": "U_BOT", "bot_id": "B1"}, PEOPLE)
assert state == "bot-token"
assert "no dependency on any human" in detail
def test_live_installer_is_reported_before_anything_breaks():
state, detail = exposure({"key": "T1", "installer": "U_LIVE"},
{"ok": True, "user_id": "U_LIVE"}, PEOPLE)
assert state == "standing-on-a-human"
assert "their last day" in detail
def test_guest_installer_is_the_most_fragile_row():
state, detail = exposure({"key": "T1", "installer": "U_GUEST"},
{"ok": True, "user_id": "U_GUEST"}, PEOPLE)
assert state == "guest-installer"
assert "deprovisioned soonest" in detail
def test_row_naming_the_wrong_human_is_unmonitored():
state, detail = exposure({"key": "T1", "installer": "U_GONE"},
{"ok": True, "user_id": "U_LIVE"}, PEOPLE)
assert state == "installer-id-drift"
assert "not this credential" in detail
def test_missing_installer_id_is_recovered_while_it_still_can_be():
state, detail = exposure({"key": "T1"}, {"ok": True, "user_id": "U_LIVE"}, PEOPLE)
assert state == "installer-not-recorded"
assert "U_LIVE" in detail
def test_installer_absent_from_the_directory():
state, _ = exposure({"key": "T1", "installer": "U_OTHER"},
{"ok": True, "user_id": "U_OTHER"}, PEOPLE)
assert state == "installer-not-in-directory"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { directory, exposure } from './slack-installer-account-watch.mjs';
const PEOPLE = directory([
{ id: 'U_LIVE', name: 'dana', deleted: false },
{ id: 'U_GONE', name: 'sam', deleted: true },
{ id: 'U_GUEST', name: 'vendor', deleted: false, is_restricted: true },
]);
test('directory keeps only what the audit reasons about', () => {
assert.deepEqual(PEOPLE.get('U_GUEST'),
{ name: 'vendor', deleted: false, is_bot: false, guest: true });
assert.equal(PEOPLE.has('U_MISSING'), false);
});
test('account_inactive is the already broken state', () => {
const [state, detail] = exposure({ key: 'T1', installer: 'U_GONE' },
{ ok: false, error: 'account_inactive' }, PEOPLE);
assert.equal(state, 'already-broken');
assert.match(detail, /still installed/);
});
test('a revoked token is not this failure', () => {
const [state, detail] = exposure({ key: 'T1' },
{ ok: false, error: 'token_revoked' }, PEOPLE);
assert.equal(state, 'other-failure');
assert.match(detail, /token_revoked/);
});
test('bot token has no exposure at all', () => {
const [state, detail] = exposure({ key: 'T1' },
{ ok: true, user_id: 'U_BOT', bot_id: 'B1' }, PEOPLE);
assert.equal(state, 'bot-token');
assert.match(detail, /no dependency on any human/);
});
test('live installer is reported before anything breaks', () => {
const [state, detail] = exposure({ key: 'T1', installer: 'U_LIVE' },
{ ok: true, user_id: 'U_LIVE' }, PEOPLE);
assert.equal(state, 'standing-on-a-human');
assert.match(detail, /their last day/);
});
test('guest installer is the most fragile row', () => {
const [state, detail] = exposure({ key: 'T1', installer: 'U_GUEST' },
{ ok: true, user_id: 'U_GUEST' }, PEOPLE);
assert.equal(state, 'guest-installer');
assert.match(detail, /deprovisioned soonest/);
});
test('row naming the wrong human is unmonitored', () => {
const [state, detail] = exposure({ key: 'T1', installer: 'U_GONE' },
{ ok: true, user_id: 'U_LIVE' }, PEOPLE);
assert.equal(state, 'installer-id-drift');
assert.match(detail, /not this credential/);
});
test('missing installer id is recovered while it still can be', () => {
const [state, detail] = exposure({ key: 'T1' }, { ok: true, user_id: 'U_LIVE' }, PEOPLE);
assert.equal(state, 'installer-not-recorded');
assert.match(detail, /U_LIVE/);
});
test('installer absent from the directory', () => {
const [state] = exposure({ key: 'T1', installer: 'U_OTHER' },
{ ok: true, user_id: 'U_OTHER' }, PEOPLE);
assert.equal(state, 'installer-not-in-directory');
});
FAQ
Does the app get uninstalled when the installer is deactivated?
No, and that is what makes this confusing. The installation survives, the app still appears in Manage apps, and bot tokens issued by that install keep working. Only the user token dies, because it is a credential belonging to a person rather than to the app.
Can I just reactivate the account to bring the token back?
Sometimes, and it is the wrong instinct. Reactivating a departed employee's account to keep a cron job alive is a licence cost and an access-control problem, and it leaves the same failure scheduled for the next person who leaves. Move the work to a bot token instead.
What if the job genuinely needs a user token?
Message search and acting-as-a-person posts have no bot equivalent, so some jobs really do need one. Install from a service account that is documented, exempt from offboarding, and owned by a team rather than a person, and monitor its deleted flag on a schedule. A service account can still be deactivated during a licence audit.
Why look at users.list rather than users.info per install?
Because one paginated sweep is a handful of calls regardless of how many installs you have, while users.info per row scales with the store and runs into the rate limiter. The sweep also gives you the deactivated count for the workspace, which is useful context for how aggressive the offboarding process is.
The token works but the directory says the user is deleted. What now?
Read it by hand rather than acting on it. The usual causes are a recently deactivated account whose token has not been swept yet, a Grid workspace where the person exists in a different member list, or a row whose recorded installer is not who the token belongs to. The script reports the disagreement instead of picking a side.
Related field notes
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.
- users.info method reference — Slack Docs
- auth.test method reference — Slack Docs
- Token types — Slack Docs
- users.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.